diff --git a/generate/template/astronomy.cs b/generate/template/astronomy.cs
index ce28eb7d..91051e48 100644
--- a/generate/template/astronomy.cs
+++ b/generate/template/astronomy.cs
@@ -3741,8 +3741,7 @@ $ASTRO_CSHARP_CHEBYSHEV(8);
for (i=0; i < npoints; ++i)
{
- double ut = t1.ut + (i * interval);
- AstroTime time = new AstroTime(ut);
+ AstroTime time = t1.AddDays(i * interval);
double dist = NeptuneHelioDistance(time);
if (i == 0)
{
@@ -3837,7 +3836,7 @@ $ASTRO_CSHARP_CHEBYSHEV(8);
{
/* There is a change of slope polarity within the time range [t1, t2]. */
/* Therefore this time range contains an apsis. */
- /* Figure out whether it is perigee or apogee. */
+ /* Figure out whether it is perihelion or aphelion. */
SearchContext_PlanetDistanceSlope slope_func;
ApsisKind kind;
diff --git a/generate/template/astronomy.py b/generate/template/astronomy.py
index 0d6f958e..57cba722 100644
--- a/generate/template/astronomy.py
+++ b/generate/template/astronomy.py
@@ -53,6 +53,7 @@ _SECONDS_PER_DAY = 24.0 * 3600.0
_SOLAR_DAYS_PER_SIDEREAL_DAY = 0.9972695717592592
_MEAN_SYNODIC_MONTH = 29.530588
_EARTH_ORBITAL_PERIOD = 365.256
+_NEPTUNE_ORBITAL_PERIOD = 60189.0
_REFRACTION_NEAR_HORIZON = 34.0 / 60.0
_SUN_RADIUS_AU = 4.6505e-3
_MOON_RADIUS_AU = 1.15717e-5
@@ -171,7 +172,7 @@ _PlanetOrbitalPeriod = [
4332.589,
10759.22,
30685.4,
- 60189.0,
+ _NEPTUNE_ORBITAL_PERIOD,
90560.0
]
@@ -200,6 +201,11 @@ class BadVectorError(Error):
def __init__(self):
Error.__init__(self, 'Vector is too small to have a direction.')
+class BadTimeError(Error):
+ """Cannot calculate Pluto position for this date/time."""
+ def __init__(self, time):
+ Error.__init__(self, 'Cannot calculate Pluto position for time: {}'.format(time))
+
class InternalError(Error):
"""An internal error occured that should be reported as a bug.
@@ -966,16 +972,17 @@ _vsop = [
$ASTRO_LIST_VSOP(Neptune),
]
+def _VsopFormula(formula, t):
+ tpower = 1.0
+ coord = 0.0
+ for series in formula:
+ coord += tpower * sum(A * math.cos(B + C*t) for (A, B, C) in series)
+ tpower *= t
+ return coord
+
def _CalcVsop(model, time):
- spher = []
t = time.tt / 365250.0
- for formula in model:
- tpower = 1.0
- coord = 0.0
- for series in formula:
- coord += tpower * sum(A * math.cos(B + C*t) for (A, B, C) in series)
- tpower *= t
- spher.append(coord)
+ spher = [_VsopFormula(formula, t) for formula in model]
# Convert spherical coordinates to ecliptic cartesian coordinates.
r_coslat = spher[2] * math.cos(spher[1])
@@ -989,6 +996,12 @@ def _CalcVsop(model, time):
vz = 0.397776982902*ey + 0.917482137087*ez
return Vector(vx, vy, vz, time)
+def _VsopHelioDistance(model, time):
+ # The caller only wants to know the distance between the planet and the Sun.
+ # So we only need to calculate the radial component of the spherical coordinates.
+ # There is no need to translate coordinates.
+ return _VsopFormula(model[2], time.tt / 365250.0)
+
def _CalcEarth(time):
return _CalcVsop(_vsop[Body.Earth], time)
@@ -1020,7 +1033,7 @@ def _CalcChebyshev(model, time):
p1 = p2
pos.append(sum - coeff[0][d]/2)
return Vector(pos[0], pos[1], pos[2], time)
- raise Error('Cannot extrapolate Chebyshev model for given Terrestrial Time: {}'.format(time.tt))
+ raise BadTimeError(time)
# END CHEBYSHEV
#----------------------------------------------------------------------------
@@ -1244,7 +1257,7 @@ def HelioVector(body, time):
if body == Body.Pluto:
return _CalcChebyshev(_pluto, time)
- if 0 <= body <= len(_vsop):
+ if 0 <= body < len(_vsop):
return _CalcVsop(_vsop[body], time)
if body == Body.Sun:
@@ -1258,6 +1271,37 @@ def HelioVector(body, time):
raise InvalidBodyError()
+def HelioDistance(body, time):
+ """Calculates the distance between a body and the Sun at a given time.
+
+ Given a date and time, this function calculates the distance between
+ the center of `body` and the center of the Sun.
+ For the planets Mercury through Neptune, this function is significantly
+ more efficient than calling #HelioVector followed by taking the length
+ of the resulting vector.
+
+ Parameters
+ ----------
+ body : Body
+ A body for which to calculate a heliocentric distance:
+ the Sun, Moon, or any of the planets.
+ time : Time
+ The date and time for which to calculate the heliocentric distance.
+
+ Returns
+ -------
+ float
+ The heliocentric distance in AU.
+ """
+ if body == Body.Sun:
+ return 0.0
+
+ if 0 <= body < len(_vsop):
+ return _VsopHelioDistance(_vsop[body], time)
+
+ return HelioVector(body, time).Length()
+
+
def GeoVector(body, time, aberration):
"""Calculates geocentric Cartesian coordinates of a body in the J2000 equatorial system.
@@ -2921,7 +2965,7 @@ def Seasons(year):
def _MoonDistance(time):
return _CalcMoon(time).distance_au
-def _distance_slope(direction, time):
+def _moon_distance_slope(direction, time):
dt = 0.001
t1 = time.AddDays(-dt/2.0)
t2 = time.AddDays(+dt/2.0)
@@ -3008,11 +3052,11 @@ def SearchLunarApsis(startTime):
"""
increment = 5.0 # number of days to skip on each iteration
t1 = startTime
- m1 = _distance_slope(+1, t1)
+ m1 = _moon_distance_slope(+1, t1)
iter = 0
while iter * increment < 2.0 * _MEAN_SYNODIC_MONTH:
t2 = t1.AddDays(increment)
- m2 = _distance_slope(+1, t2)
+ m2 = _moon_distance_slope(+1, t2)
if m1 * m2 <= 0.0:
# There is a change of slope polarity within the time range [t1, t2].
# Therefore this time range contains an apsis.
@@ -3020,12 +3064,12 @@ def SearchLunarApsis(startTime):
if m1 < 0.0 or m2 > 0.0:
# We found a minimum-distance event: perigee.
# Search the time range for the time when the slope goes from negative to positive.
- apsis_time = Search(_distance_slope, +1, t1, t2, 1.0)
+ apsis_time = Search(_moon_distance_slope, +1, t1, t2, 1.0)
kind = ApsisKind.Pericenter
elif m1 > 0.0 or m2 < 0.0:
# We found a maximum-distance event: apogee.
# Search the time range for the time when the slope goes from positive to negative.
- apsis_time = Search(_distance_slope, -1, t1, t2, 1.0)
+ apsis_time = Search(_moon_distance_slope, -1, t1, t2, 1.0)
kind = ApsisKind.Apocenter
else:
# This should never happen. It should not be possible for both slopes to be zero.
@@ -3072,10 +3116,189 @@ def NextLunarApsis(apsis):
if apsis.kind not in [ApsisKind.Apocenter, ApsisKind.Pericenter]:
raise Error('Parameter "apsis" contains an invalid "kind" value.')
if next.kind + apsis.kind != 1:
- raise InternalError()
+ raise InternalError() # should have found opposite apsis kind
return next
+def _planet_distance_slope(context, time):
+ (direction, body) = context
+ dt = 0.001
+ t1 = time.AddDays(-dt/2.0)
+ t2 = time.AddDays(+dt/2.0)
+ dist1 = HelioDistance(body, t1)
+ dist2 = HelioDistance(body, t2)
+ return direction * (dist2 - dist1) / dt
+
+
+def SearchPlanetApsis(body, startTime):
+ """Finds the next planet perihelion or aphelion, after a given time.
+
+ Given a date and time to start the search in `startTime`, this function finds the
+ next date and time that the center of the specified planet reaches the closest or farthest point
+ in its orbit with respect to the center of the Sun, whichever comes first after `startTime`.
+
+ The closest point is called *perihelion* and the farthest point is called *aphelion*.
+ The word *apsis* refers to either event.
+
+ To iterate through consecutive alternating perihelion and aphelion events,
+ call `SearchPlanetApsis` once, then use the return value to call #NextPlanetApsis.
+ After that, keep feeding the previous return value from `NextPlanetApsis`
+ into another call of `NextPlanetApsis` as many times as desired.
+
+ Parameters
+ ----------
+ body : Body
+ The planet for which to find the next perihelion/aphelion event.
+ Not allowed to be `Body.Sun` or `Body.Moon`.
+ startTime : Time
+ The date and time at which to start searching for the next perihelion or aphelion.
+
+ Returns
+ -------
+ Apsis
+ """
+ if body == Body.Neptune:
+ return _SearchNeptuneApsis(startTime)
+ positive_slope = (+1.0, body)
+ negative_slope = (-1.0, body)
+ orbit_period_days = _PlanetOrbitalPeriod[body]
+ increment = orbit_period_days / 6.0
+ t1 = startTime
+ m1 = _planet_distance_slope(positive_slope, t1)
+ iter = 0
+ while iter * increment < 2 * orbit_period_days:
+ t2 = t1.AddDays(increment)
+ m2 = _planet_distance_slope(positive_slope, t2)
+ if m1 * m2 <= 0.0:
+ # There is a change of slope polarity within the time range [t1, t2].
+ # Therefore this time range contains an apsis.
+ # Figure out whether it is perihelion or aphelion.
+ if m1 < 0.0 or m2 > 0.0:
+ # We found a minimum-distance event: perihelion.
+ # Search the time range for the time when the slope goes from negative to positive.
+ context = positive_slope
+ kind = ApsisKind.Pericenter
+ elif m1 > 0.0 or m2 < 0.0:
+ # We found a maximum-distance event: aphelion.
+ # Search the time range for the time when the slope goes from positive to negative.
+ context = negative_slope
+ kind = ApsisKind.Apocenter
+ else:
+ raise InternalError() # at least one of the planet distance slopes should have been nonzero
+ search = Search(_planet_distance_slope, context, t1, t2, 1.0)
+ if search is None:
+ raise InternalError() # failed to find where planet distance slope passed through zero
+
+ dist = HelioDistance(body, search)
+ return Apsis(search, kind, dist)
+ t1 = t2
+ m1 = m2
+ iter += 1
+ raise InternalError() # should have found planet apsis within 2 planet orbits
+
+
+def NextPlanetApsis(body, apsis):
+ """Finds the next planetary perihelion or aphelion event in a series.
+
+ This function requires an #Apsis value obtained from a call
+ to #SearchPlanetApsis or `NextPlanetApsis`.
+ Given an aphelion event, this function finds the next perihelion event, and vice versa.
+ See #SearchPlanetApsis for more details.
+
+ Parameters
+ ----------
+ body : Body
+ The planet for which to find the next perihelion/aphelion event.
+ Not allowed to be `Body.Sun` or `Body.Moon`.
+ Must match the body passed into the call that produced the `apsis` parameter.
+ apsis : Apsis
+ An apsis event obtained from a call to #SearchPlanetApsis or `NextPlanetApsis`.
+
+ Returns
+ -------
+ Apsis
+ """
+ if apsis.kind not in [ApsisKind.Apocenter, ApsisKind.Pericenter]:
+ raise Error('Parameter "apsis" contains an invalid "kind" value.')
+ # Skip 1/4 of an orbit before starting search again.
+ skip = 0.25 * _PlanetOrbitalPeriod[body]
+ time = apsis.time.AddDays(skip)
+ next = SearchPlanetApsis(body, time)
+ # Verify that we found the opposite apsis from the previous one.
+ if next.kind + apsis.kind != 1:
+ raise InternalError() # should have found opposite planetary apsis type
+ return next
+
+def _NeptuneHelioDistance(time):
+ return _VsopHelioDistance(_vsop[Body.Neptune], time)
+
+def _NeptuneExtreme(kind, start_time, dayspan):
+ direction = +1.0 if (kind == ApsisKind.Apocenter) else -1.0
+ npoints = 10
+ while True:
+ interval = dayspan / (npoints - 1)
+ # Iterate until uncertainty is less than one minute.
+ if interval < 1/1440:
+ apsis_time = start_time.AddDays(interval/2)
+ dist_au = _NeptuneHelioDistance(apsis_time)
+ return Apsis(apsis_time, kind, dist_au)
+ for i in range(npoints):
+ time = start_time.AddDays(i * interval)
+ dist = direction * _NeptuneHelioDistance(time)
+ if i==0 or dist > best_dist:
+ best_i = i
+ best_dist = dist
+ # Narrow in on the extreme point.
+ start_time = start_time.AddDays((best_i - 1) * interval)
+ dayspan = 2 * interval
+
+def _SearchNeptuneApsis(startTime):
+ # Neptune is a special case for two reasons:
+ # 1. Its orbit is nearly circular (low orbital eccentricity).
+ # 2. It is so distant from the Sun that the orbital period is very long.
+ # Put together, this causes wobbling of the Sun around the Solar System Barycenter (SSB)
+ # to be so significant that there are 3 local minima in the distance-vs-time curve
+ # near each apsis. Therefore, unlike for other planets, we can't use an optimized
+ # algorithm for finding dr/dt = 0.
+ # Instead, we use a dumb, brute-force algorithm of sampling and finding min/max
+ # heliocentric distance.
+ #
+ # Rewind approximately 30 degrees in the orbit,
+ # then search forward for 270 degrees.
+ # This is a very cautious way to prevent missing an apsis.
+ # Typically we will find two apsides, and we pick whichever
+ # apsis is ealier, but after startTime.
+ # Sample points around this orbital arc and find when the distance
+ # is greatest and smallest.
+ t1 = startTime.AddDays(_NEPTUNE_ORBITAL_PERIOD * ( -30 / 360))
+ t2 = startTime.AddDays(_NEPTUNE_ORBITAL_PERIOD * (+270 / 360))
+ t_min = t_max = t1
+ npoints = 1000
+ interval = (t2.ut - t1.ut) / (npoints - 1)
+
+ for i in range(npoints):
+ time = t1.AddDays(i * interval)
+ dist = _NeptuneHelioDistance(time)
+ if i == 0:
+ min_dist = max_dist = dist
+ else:
+ if dist > max_dist:
+ max_dist = dist
+ t_max = time
+ if dist < min_dist:
+ min_dist = dist
+ t_min = time
+
+ perihelion = _NeptuneExtreme(ApsisKind.Pericenter, t_min.AddDays(-2*interval), 4*interval)
+ aphelion = _NeptuneExtreme(ApsisKind.Apocenter, t_max.AddDays(-2*interval), 4*interval)
+ if perihelion.time.tt >= startTime.tt:
+ if startTime.tt <= aphelion.time.tt < perihelion.time.tt:
+ return aphelion
+ return perihelion
+ if aphelion.time.tt >= startTime.tt:
+ return aphelion
+ raise InternalError() # failed to find Neptune apsis
+
def VectorFromSphere(sphere, time):
"""Converts spherical coordinates to Cartesian coordinates.
diff --git a/generate/test.py b/generate/test.py
index bd5d00a2..07b974b9 100755
--- a/generate/test.py
+++ b/generate/test.py
@@ -2,6 +2,7 @@
import sys
import math
import re
+import os
sys.path.append('../source/python')
import astronomy
@@ -432,22 +433,10 @@ ElongTestData = [
( astronomy.Body.Venus, "2018-08-07T17:02Z", "2018-08-17T17:02Z", 45.90, 'evening' )
]
-def ParseDate(text):
- m = re.match(r'^(\d+)-(\d+)-(\d+)T(\d+):(\d+)Z$', text)
- if not m:
- print('ParseDate: invalid date text "{}"'.format(text))
- raise Exception('Bad elongation test data')
- year = int(m.group(1))
- month = int(m.group(2))
- day = int(m.group(3))
- hour = int(m.group(4))
- minute = int(m.group(5))
- return astronomy.Time.Make(year, month, day, hour, minute, 0)
-
def TestMaxElong(body, searchText, eventText, angle, visibility):
name = body.name
- searchTime = ParseDate(searchText)
- eventTime = ParseDate(eventText)
+ searchTime = astronomy.Time.Parse(searchText)
+ eventTime = astronomy.Time.Parse(eventText)
evt = astronomy.SearchMaxElongation(body, searchTime)
if evt is None:
print('TestMaxElong({} {}): SearchMaxElongation failed.'.format(name, searchText))
@@ -551,7 +540,7 @@ def CheckSaturn():
]
error = 0
for (dtext, mag, tilt) in data:
- time = ParseDate(dtext)
+ time = astronomy.Time.Parse(dtext)
illum = astronomy.Illumination(astronomy.Body.Saturn, time)
print('Saturn: date={} calc mag={:12.8f} ring_tilt={:12.8f}'.format(dtext, illum.mag, illum.ring_tilt))
mag_diff = abs(illum.mag - mag)
@@ -580,8 +569,8 @@ def TestMaxMag(body, filename):
line = line.strip()
tokenlist = line.split()
if len(tokenlist) == 5:
- time1 = ParseDate(tokenlist[0])
- time2 = ParseDate(tokenlist[1])
+ time1 = astronomy.Time.Parse(tokenlist[0])
+ time2 = astronomy.Time.Parse(tokenlist[1])
if time1 and time2:
center_time = time1.AddDays(0.5*(time2.ut - time1.ut))
correct_mag = float(tokenlist[4])
@@ -727,7 +716,7 @@ def LunarApsis(filename):
if len(tokenlist) != 3:
print('LunarApsis({} line {}): invalid data format'.format(filename, lnum))
return 1
- correct_time = ParseDate(tokenlist[1])
+ correct_time = astronomy.Time.Parse(tokenlist[1])
if not correct_time:
print('LunarApsis({} line {}): invalid time'.format(filename, lnum))
return 1
@@ -749,12 +738,6 @@ def LunarApsis(filename):
print('LunarApsis: found {} events, max time error = {:0.3f} minutes, max distance error = {:0.3f} km.'.format(lnum, max_minutes, max_km))
return 0
-
-def Test_Apsis():
- if 0 != LunarApsis('apsides/moon.txt'):
- return 1
- return 0
-
#-----------------------------------------------------------------------------------------------------------
def CompareMatrices(caller, a, b, tolerance):
@@ -1051,6 +1034,78 @@ def Test_Refraction():
#-----------------------------------------------------------------------------------------------------------
+def Test_PlanetApsis():
+ degree_threshold = 0.1
+ start_time = astronomy.Time.Make(1700, 1, 1, 0, 0, 0)
+ found_bad_planet = False
+ body = astronomy.Body.Mercury
+ while body <= astronomy.Body.Pluto:
+ count = 1
+ period = astronomy._PlanetOrbitalPeriod[body]
+ filename = os.path.join('apsides', 'apsis_{}.txt'.format(int(body)))
+ min_interval = -1.0
+ max_diff_days = 0.0
+ max_dist_ratio = 0.0
+ apsis = astronomy.SearchPlanetApsis(body, start_time)
+ with open(filename, 'rt') as infile:
+ for line in infile:
+ token = line.split()
+ if len(token) != 3:
+ print('Test_PlanetApsis({} line {}): Invalid data format: {} tokens'.format(filename, count, len(token)))
+ return 1
+ expected_kind = int(token[0])
+ expected_time = astronomy.Time.Parse(token[1])
+ expected_distance = float(token[2])
+ if apsis.kind != expected_kind:
+ print('Test_PlanetApsis({} line {}): WRONG APSIS KIND: expected {}, found {}'.format(filename, count, expected_kind, apsis.kind))
+ return 1
+ diff_days = abs(expected_time.tt - apsis.time.tt)
+ max_diff_days = max(max_diff_days, diff_days)
+ diff_degrees = (diff_days / period) * 360
+ if diff_degrees > degree_threshold:
+ found_bad_planet = True
+ diff_dist_ratio = abs(expected_distance - apsis.dist_au) / expected_distance
+ max_dist_ratio = max(max_dist_ratio, diff_dist_ratio)
+ if diff_dist_ratio > 1.0e-4:
+ print('Test_PlanetApsis({} line {}): distance ratio {} is too large.'.format(filename, count, diff_dist_ratio))
+ return 1
+
+ # Calculate the next apsis.
+ prev_time = apsis.time
+ try:
+ apsis = astronomy.NextPlanetApsis(body, apsis)
+ except astronomy.BadTimeError:
+ if body != astronomy.Body.Pluto:
+ raise
+ break # It is OK for us to go beyond Pluto calculation's time domain.
+ count += 1
+ interval = apsis.time.tt - prev_time.tt
+ if min_interval < 0.0:
+ min_interval = max_interval = interval
+ else:
+ min_interval = min(min_interval, interval)
+ max_interval = max(max_interval, interval)
+ if count < 2:
+ print('Test_PlanetApsis: FAILED to find apsides for {}'.format(body))
+ return 1
+ print('Test_PlanetApsis: {:4d} apsides for {:<9s} -- intervals: min={:0.2f}, max={:0.2f}, ratio={:0.6f}; max day={:0.3f}, degrees={:0.3f}, dist ratio={:0.6f}'.format(
+ count,
+ body.name,
+ min_interval, max_interval, max_interval / min_interval,
+ max_diff_days,
+ (max_diff_days / period) * 360.0,
+ max_dist_ratio
+ ))
+ body = astronomy.Body(body + 1)
+
+ if found_bad_planet:
+ print('Test_PlanetApsis: FAIL - planet(s) exceeded angular threshold ({} degrees)'.format(degree_threshold))
+ return 1
+ print('Test_PlanetApsis: PASS')
+ return 0
+
+#-----------------------------------------------------------------------------------------------------------
+
if __name__ == '__main__':
if len(sys.argv) == 2:
if sys.argv[1] == 'time':
@@ -1066,8 +1121,10 @@ if __name__ == '__main__':
sys.exit(Test_Elongation())
if sys.argv[1] == 'magnitude':
sys.exit(Test_Magnitude())
- if sys.argv[1] == 'apsis':
- sys.exit(Test_Apsis())
+ if sys.argv[1] == 'lunar_apsis':
+ sys.exit(LunarApsis('apsides/moon.txt'))
+ if sys.argv[1] == 'planet_apsis':
+ sys.exit(Test_PlanetApsis())
if sys.argv[1] == 'issue46':
sys.exit(Test_Issue46())
if sys.argv[1] == 'rotation':
diff --git a/generate/unit_test_python b/generate/unit_test_python
index 4533b957..ba6acf0f 100755
--- a/generate/unit_test_python
+++ b/generate/unit_test_python
@@ -9,7 +9,8 @@ python3 --version || Fail "Cannot print python version"
python3 test.py rotation || Fail "Failed Python rotation tests."
python3 test.py refraction || Fail "Failed Python refraction tests."
python3 test.py time || Fail "Failure reported by test.py (time)"
-python3 test.py apsis || Fail "Failed Python apsis tests."
+python3 test.py lunar_apsis || Fail "Failed Python lunar apsis tests."
+python3 test.py planet_apsis || Fail "Failed Python planet apsis tests."
python3 test.py magnitude || Fail "Failed Python magnitude tests."
python3 test.py moon || Fail "Failure reported by test.py (moon)"
python3 test.py seasons seasons/seasons.txt || Fail "Failed Python seasons test."
@@ -27,4 +28,5 @@ time python3 test.py astro_check > temp/py_check.txt || Fail "Failure in Python
./generate check temp/py_check.txt || Fail "Verification failure for Python unit test output."
./ctest diff temp/{py,c}_check.txt || Fail "Diff(py,js) failure."
+echo "$0: PASS"
exit 0
diff --git a/pydown/py_prefix.md b/pydown/py_prefix.md
index a5120427..30d9a24f 100644
--- a/pydown/py_prefix.md
+++ b/pydown/py_prefix.md
@@ -59,6 +59,13 @@ To get started quickly, here are some [examples](../../demo/python/).
| [SearchLunarApsis](#SearchLunarApsis) | Finds the next perigee or apogee of the Moon after a specified date. |
| [NextLunarApsis](#NextLunarApsis) | Given an already-found apsis, finds the next perigee or apogee of the Moon. |
+### Planet perihelion and aphelion
+
+| Function | Description |
+| -------- | ----------- |
+| [SearchPlanetApsis](#SearchPlanetApsis) | Finds the next perihelion or aphelion of a planet after a specified date. |
+| [NextPlanetApsis](#NextPlanetApsis) | Given an already-found apsis, finds the next perihelion or aphelion of a planet. |
+
### Visual magnitude and elongation
| Function | Description |
diff --git a/source/csharp/astronomy.cs b/source/csharp/astronomy.cs
index 823788d3..ffd4278b 100644
--- a/source/csharp/astronomy.cs
+++ b/source/csharp/astronomy.cs
@@ -5037,8 +5037,7 @@ namespace CosineKitty
for (i=0; i < npoints; ++i)
{
- double ut = t1.ut + (i * interval);
- AstroTime time = new AstroTime(ut);
+ AstroTime time = t1.AddDays(i * interval);
double dist = NeptuneHelioDistance(time);
if (i == 0)
{
@@ -5133,7 +5132,7 @@ namespace CosineKitty
{
/* There is a change of slope polarity within the time range [t1, t2]. */
/* Therefore this time range contains an apsis. */
- /* Figure out whether it is perigee or apogee. */
+ /* Figure out whether it is perihelion or aphelion. */
SearchContext_PlanetDistanceSlope slope_func;
ApsisKind kind;
diff --git a/source/python/README.md b/source/python/README.md
index 87d0a2a7..fe4f19ea 100644
--- a/source/python/README.md
+++ b/source/python/README.md
@@ -59,6 +59,13 @@ To get started quickly, here are some [examples](../../demo/python/).
| [SearchLunarApsis](#SearchLunarApsis) | Finds the next perigee or apogee of the Moon after a specified date. |
| [NextLunarApsis](#NextLunarApsis) | Given an already-found apsis, finds the next perigee or apogee of the Moon. |
+### Planet perihelion and aphelion
+
+| Function | Description |
+| -------- | ----------- |
+| [SearchPlanetApsis](#SearchPlanetApsis) | Finds the next perihelion or aphelion of a planet after a specified date. |
+| [NextPlanetApsis](#NextPlanetApsis) | Given an already-found apsis, finds the next perihelion or aphelion of a planet. |
+
### Visual magnitude and elongation
| Function | Description |
@@ -550,6 +557,13 @@ as seen by an observer on the surface of the Earth.
---
+
+### BadTimeError
+
+Cannot calculate Pluto position for this date/time.
+
+---
+
### BadVectorError
@@ -842,6 +856,27 @@ A geocentric position vector of the center of the given body.
---
+
+### HelioDistance(body, time)
+
+**Calculates the distance between a body and the Sun at a given time.**
+
+Given a date and time, this function calculates the distance between
+the center of `body` and the center of the Sun.
+For the planets Mercury through Neptune, this function is significantly
+more efficient than calling [`HelioVector`](#HelioVector) followed by taking the length
+of the resulting vector.
+
+| Type | Parameter | Description |
+| --- | --- | --- |
+| [`Body`](#Body) | `body` | A body for which to calculate a heliocentric distance: the Sun, Moon, or any of the planets. |
+| [`Time`](#Time) | `time` | The date and time for which to calculate the heliocentric distance. |
+
+### Returns: `float`
+The heliocentric distance in AU.
+
+---
+
### HelioVector(body, time)
@@ -1096,6 +1131,25 @@ the one passed in as the parameter `mq`.
---
+
+### NextPlanetApsis(body, apsis)
+
+**Finds the next planetary perihelion or aphelion event in a series.**
+
+This function requires an [`Apsis`](#Apsis) value obtained from a call
+to [`SearchPlanetApsis`](#SearchPlanetApsis) or `NextPlanetApsis`.
+Given an aphelion event, this function finds the next perihelion event, and vice versa.
+See [`SearchPlanetApsis`](#SearchPlanetApsis) for more details.
+
+| Type | Parameter | Description |
+| --- | --- | --- |
+| [`Body`](#Body) | `body` | The planet for which to find the next perihelion/aphelion event. Not allowed to be `Body.Sun` or `Body.Moon`. Must match the body passed into the call that produced the `apsis` parameter. |
+| [`Apsis`](#Apsis) | `apsis` | An apsis event obtained from a call to [`SearchPlanetApsis`](#SearchPlanetApsis) or `NextPlanetApsis`. |
+
+### Returns: [`Apsis`](#Apsis)
+
+---
+
### RefractionAngle(refraction, altitude)
@@ -1597,6 +1651,30 @@ However, the difference is minor and has little practical value.
---
+
+### SearchPlanetApsis(body, startTime)
+
+**Finds the next planet perihelion or aphelion, after a given time.**
+
+Given a date and time to start the search in `startTime`, this function finds the
+next date and time that the center of the specified planet reaches the closest or farthest point
+in its orbit with respect to the center of the Sun, whichever comes first after `startTime`.
+The closest point is called *perihelion* and the farthest point is called *aphelion*.
+The word *apsis* refers to either event.
+To iterate through consecutive alternating perihelion and aphelion events,
+call `SearchPlanetApsis` once, then use the return value to call [`NextPlanetApsis`](#NextPlanetApsis).
+After that, keep feeding the previous return value from `NextPlanetApsis`
+into another call of `NextPlanetApsis` as many times as desired.
+
+| Type | Parameter | Description |
+| --- | --- | --- |
+| [`Body`](#Body) | `body` | The planet for which to find the next perihelion/aphelion event. Not allowed to be `Body.Sun` or `Body.Moon`. |
+| [`Time`](#Time) | `startTime` | The date and time at which to start searching for the next perihelion or aphelion. |
+
+### Returns: [`Apsis`](#Apsis)
+
+---
+
### SearchRelativeLongitude(body, targetRelLon, startTime)
diff --git a/source/python/astronomy.py b/source/python/astronomy.py
index c341a09a..64422608 100644
--- a/source/python/astronomy.py
+++ b/source/python/astronomy.py
@@ -53,6 +53,7 @@ _SECONDS_PER_DAY = 24.0 * 3600.0
_SOLAR_DAYS_PER_SIDEREAL_DAY = 0.9972695717592592
_MEAN_SYNODIC_MONTH = 29.530588
_EARTH_ORBITAL_PERIOD = 365.256
+_NEPTUNE_ORBITAL_PERIOD = 60189.0
_REFRACTION_NEAR_HORIZON = 34.0 / 60.0
_SUN_RADIUS_AU = 4.6505e-3
_MOON_RADIUS_AU = 1.15717e-5
@@ -171,7 +172,7 @@ _PlanetOrbitalPeriod = [
4332.589,
10759.22,
30685.4,
- 60189.0,
+ _NEPTUNE_ORBITAL_PERIOD,
90560.0
]
@@ -200,6 +201,11 @@ class BadVectorError(Error):
def __init__(self):
Error.__init__(self, 'Vector is too small to have a direction.')
+class BadTimeError(Error):
+ """Cannot calculate Pluto position for this date/time."""
+ def __init__(self, time):
+ Error.__init__(self, 'Cannot calculate Pluto position for time: {}'.format(time))
+
class InternalError(Error):
"""An internal error occured that should be reported as a bug.
@@ -2908,16 +2914,17 @@ _vsop = [
],
]
+def _VsopFormula(formula, t):
+ tpower = 1.0
+ coord = 0.0
+ for series in formula:
+ coord += tpower * sum(A * math.cos(B + C*t) for (A, B, C) in series)
+ tpower *= t
+ return coord
+
def _CalcVsop(model, time):
- spher = []
t = time.tt / 365250.0
- for formula in model:
- tpower = 1.0
- coord = 0.0
- for series in formula:
- coord += tpower * sum(A * math.cos(B + C*t) for (A, B, C) in series)
- tpower *= t
- spher.append(coord)
+ spher = [_VsopFormula(formula, t) for formula in model]
# Convert spherical coordinates to ecliptic cartesian coordinates.
r_coslat = spher[2] * math.cos(spher[1])
@@ -2931,6 +2938,12 @@ def _CalcVsop(model, time):
vz = 0.397776982902*ey + 0.917482137087*ez
return Vector(vx, vy, vz, time)
+def _VsopHelioDistance(model, time):
+ # The caller only wants to know the distance between the planet and the Sun.
+ # So we only need to calculate the radial component of the spherical coordinates.
+ # There is no need to translate coordinates.
+ return _VsopFormula(model[2], time.tt / 365250.0)
+
def _CalcEarth(time):
return _CalcVsop(_vsop[Body.Earth], time)
@@ -3130,7 +3143,7 @@ def _CalcChebyshev(model, time):
p1 = p2
pos.append(sum - coeff[0][d]/2)
return Vector(pos[0], pos[1], pos[2], time)
- raise Error('Cannot extrapolate Chebyshev model for given Terrestrial Time: {}'.format(time.tt))
+ raise BadTimeError(time)
# END CHEBYSHEV
#----------------------------------------------------------------------------
@@ -3354,7 +3367,7 @@ def HelioVector(body, time):
if body == Body.Pluto:
return _CalcChebyshev(_pluto, time)
- if 0 <= body <= len(_vsop):
+ if 0 <= body < len(_vsop):
return _CalcVsop(_vsop[body], time)
if body == Body.Sun:
@@ -3368,6 +3381,37 @@ def HelioVector(body, time):
raise InvalidBodyError()
+def HelioDistance(body, time):
+ """Calculates the distance between a body and the Sun at a given time.
+
+ Given a date and time, this function calculates the distance between
+ the center of `body` and the center of the Sun.
+ For the planets Mercury through Neptune, this function is significantly
+ more efficient than calling #HelioVector followed by taking the length
+ of the resulting vector.
+
+ Parameters
+ ----------
+ body : Body
+ A body for which to calculate a heliocentric distance:
+ the Sun, Moon, or any of the planets.
+ time : Time
+ The date and time for which to calculate the heliocentric distance.
+
+ Returns
+ -------
+ float
+ The heliocentric distance in AU.
+ """
+ if body == Body.Sun:
+ return 0.0
+
+ if 0 <= body < len(_vsop):
+ return _VsopHelioDistance(_vsop[body], time)
+
+ return HelioVector(body, time).Length()
+
+
def GeoVector(body, time, aberration):
"""Calculates geocentric Cartesian coordinates of a body in the J2000 equatorial system.
@@ -5031,7 +5075,7 @@ def Seasons(year):
def _MoonDistance(time):
return _CalcMoon(time).distance_au
-def _distance_slope(direction, time):
+def _moon_distance_slope(direction, time):
dt = 0.001
t1 = time.AddDays(-dt/2.0)
t2 = time.AddDays(+dt/2.0)
@@ -5118,11 +5162,11 @@ def SearchLunarApsis(startTime):
"""
increment = 5.0 # number of days to skip on each iteration
t1 = startTime
- m1 = _distance_slope(+1, t1)
+ m1 = _moon_distance_slope(+1, t1)
iter = 0
while iter * increment < 2.0 * _MEAN_SYNODIC_MONTH:
t2 = t1.AddDays(increment)
- m2 = _distance_slope(+1, t2)
+ m2 = _moon_distance_slope(+1, t2)
if m1 * m2 <= 0.0:
# There is a change of slope polarity within the time range [t1, t2].
# Therefore this time range contains an apsis.
@@ -5130,12 +5174,12 @@ def SearchLunarApsis(startTime):
if m1 < 0.0 or m2 > 0.0:
# We found a minimum-distance event: perigee.
# Search the time range for the time when the slope goes from negative to positive.
- apsis_time = Search(_distance_slope, +1, t1, t2, 1.0)
+ apsis_time = Search(_moon_distance_slope, +1, t1, t2, 1.0)
kind = ApsisKind.Pericenter
elif m1 > 0.0 or m2 < 0.0:
# We found a maximum-distance event: apogee.
# Search the time range for the time when the slope goes from positive to negative.
- apsis_time = Search(_distance_slope, -1, t1, t2, 1.0)
+ apsis_time = Search(_moon_distance_slope, -1, t1, t2, 1.0)
kind = ApsisKind.Apocenter
else:
# This should never happen. It should not be possible for both slopes to be zero.
@@ -5182,10 +5226,189 @@ def NextLunarApsis(apsis):
if apsis.kind not in [ApsisKind.Apocenter, ApsisKind.Pericenter]:
raise Error('Parameter "apsis" contains an invalid "kind" value.')
if next.kind + apsis.kind != 1:
- raise InternalError()
+ raise InternalError() # should have found opposite apsis kind
return next
+def _planet_distance_slope(context, time):
+ (direction, body) = context
+ dt = 0.001
+ t1 = time.AddDays(-dt/2.0)
+ t2 = time.AddDays(+dt/2.0)
+ dist1 = HelioDistance(body, t1)
+ dist2 = HelioDistance(body, t2)
+ return direction * (dist2 - dist1) / dt
+
+
+def SearchPlanetApsis(body, startTime):
+ """Finds the next planet perihelion or aphelion, after a given time.
+
+ Given a date and time to start the search in `startTime`, this function finds the
+ next date and time that the center of the specified planet reaches the closest or farthest point
+ in its orbit with respect to the center of the Sun, whichever comes first after `startTime`.
+
+ The closest point is called *perihelion* and the farthest point is called *aphelion*.
+ The word *apsis* refers to either event.
+
+ To iterate through consecutive alternating perihelion and aphelion events,
+ call `SearchPlanetApsis` once, then use the return value to call #NextPlanetApsis.
+ After that, keep feeding the previous return value from `NextPlanetApsis`
+ into another call of `NextPlanetApsis` as many times as desired.
+
+ Parameters
+ ----------
+ body : Body
+ The planet for which to find the next perihelion/aphelion event.
+ Not allowed to be `Body.Sun` or `Body.Moon`.
+ startTime : Time
+ The date and time at which to start searching for the next perihelion or aphelion.
+
+ Returns
+ -------
+ Apsis
+ """
+ if body == Body.Neptune:
+ return _SearchNeptuneApsis(startTime)
+ positive_slope = (+1.0, body)
+ negative_slope = (-1.0, body)
+ orbit_period_days = _PlanetOrbitalPeriod[body]
+ increment = orbit_period_days / 6.0
+ t1 = startTime
+ m1 = _planet_distance_slope(positive_slope, t1)
+ iter = 0
+ while iter * increment < 2 * orbit_period_days:
+ t2 = t1.AddDays(increment)
+ m2 = _planet_distance_slope(positive_slope, t2)
+ if m1 * m2 <= 0.0:
+ # There is a change of slope polarity within the time range [t1, t2].
+ # Therefore this time range contains an apsis.
+ # Figure out whether it is perihelion or aphelion.
+ if m1 < 0.0 or m2 > 0.0:
+ # We found a minimum-distance event: perihelion.
+ # Search the time range for the time when the slope goes from negative to positive.
+ context = positive_slope
+ kind = ApsisKind.Pericenter
+ elif m1 > 0.0 or m2 < 0.0:
+ # We found a maximum-distance event: aphelion.
+ # Search the time range for the time when the slope goes from positive to negative.
+ context = negative_slope
+ kind = ApsisKind.Apocenter
+ else:
+ raise InternalError() # at least one of the planet distance slopes should have been nonzero
+ search = Search(_planet_distance_slope, context, t1, t2, 1.0)
+ if search is None:
+ raise InternalError() # failed to find where planet distance slope passed through zero
+
+ dist = HelioDistance(body, search)
+ return Apsis(search, kind, dist)
+ t1 = t2
+ m1 = m2
+ iter += 1
+ raise InternalError() # should have found planet apsis within 2 planet orbits
+
+
+def NextPlanetApsis(body, apsis):
+ """Finds the next planetary perihelion or aphelion event in a series.
+
+ This function requires an #Apsis value obtained from a call
+ to #SearchPlanetApsis or `NextPlanetApsis`.
+ Given an aphelion event, this function finds the next perihelion event, and vice versa.
+ See #SearchPlanetApsis for more details.
+
+ Parameters
+ ----------
+ body : Body
+ The planet for which to find the next perihelion/aphelion event.
+ Not allowed to be `Body.Sun` or `Body.Moon`.
+ Must match the body passed into the call that produced the `apsis` parameter.
+ apsis : Apsis
+ An apsis event obtained from a call to #SearchPlanetApsis or `NextPlanetApsis`.
+
+ Returns
+ -------
+ Apsis
+ """
+ if apsis.kind not in [ApsisKind.Apocenter, ApsisKind.Pericenter]:
+ raise Error('Parameter "apsis" contains an invalid "kind" value.')
+ # Skip 1/4 of an orbit before starting search again.
+ skip = 0.25 * _PlanetOrbitalPeriod[body]
+ time = apsis.time.AddDays(skip)
+ next = SearchPlanetApsis(body, time)
+ # Verify that we found the opposite apsis from the previous one.
+ if next.kind + apsis.kind != 1:
+ raise InternalError() # should have found opposite planetary apsis type
+ return next
+
+def _NeptuneHelioDistance(time):
+ return _VsopHelioDistance(_vsop[Body.Neptune], time)
+
+def _NeptuneExtreme(kind, start_time, dayspan):
+ direction = +1.0 if (kind == ApsisKind.Apocenter) else -1.0
+ npoints = 10
+ while True:
+ interval = dayspan / (npoints - 1)
+ # Iterate until uncertainty is less than one minute.
+ if interval < 1/1440:
+ apsis_time = start_time.AddDays(interval/2)
+ dist_au = _NeptuneHelioDistance(apsis_time)
+ return Apsis(apsis_time, kind, dist_au)
+ for i in range(npoints):
+ time = start_time.AddDays(i * interval)
+ dist = direction * _NeptuneHelioDistance(time)
+ if i==0 or dist > best_dist:
+ best_i = i
+ best_dist = dist
+ # Narrow in on the extreme point.
+ start_time = start_time.AddDays((best_i - 1) * interval)
+ dayspan = 2 * interval
+
+def _SearchNeptuneApsis(startTime):
+ # Neptune is a special case for two reasons:
+ # 1. Its orbit is nearly circular (low orbital eccentricity).
+ # 2. It is so distant from the Sun that the orbital period is very long.
+ # Put together, this causes wobbling of the Sun around the Solar System Barycenter (SSB)
+ # to be so significant that there are 3 local minima in the distance-vs-time curve
+ # near each apsis. Therefore, unlike for other planets, we can't use an optimized
+ # algorithm for finding dr/dt = 0.
+ # Instead, we use a dumb, brute-force algorithm of sampling and finding min/max
+ # heliocentric distance.
+ #
+ # Rewind approximately 30 degrees in the orbit,
+ # then search forward for 270 degrees.
+ # This is a very cautious way to prevent missing an apsis.
+ # Typically we will find two apsides, and we pick whichever
+ # apsis is ealier, but after startTime.
+ # Sample points around this orbital arc and find when the distance
+ # is greatest and smallest.
+ t1 = startTime.AddDays(_NEPTUNE_ORBITAL_PERIOD * ( -30 / 360))
+ t2 = startTime.AddDays(_NEPTUNE_ORBITAL_PERIOD * (+270 / 360))
+ t_min = t_max = t1
+ npoints = 1000
+ interval = (t2.ut - t1.ut) / (npoints - 1)
+
+ for i in range(npoints):
+ time = t1.AddDays(i * interval)
+ dist = _NeptuneHelioDistance(time)
+ if i == 0:
+ min_dist = max_dist = dist
+ else:
+ if dist > max_dist:
+ max_dist = dist
+ t_max = time
+ if dist < min_dist:
+ min_dist = dist
+ t_min = time
+
+ perihelion = _NeptuneExtreme(ApsisKind.Pericenter, t_min.AddDays(-2*interval), 4*interval)
+ aphelion = _NeptuneExtreme(ApsisKind.Apocenter, t_max.AddDays(-2*interval), 4*interval)
+ if perihelion.time.tt >= startTime.tt:
+ if startTime.tt <= aphelion.time.tt < perihelion.time.tt:
+ return aphelion
+ return perihelion
+ if aphelion.time.tt >= startTime.tt:
+ return aphelion
+ raise InternalError() # failed to find Neptune apsis
+
def VectorFromSphere(sphere, time):
"""Converts spherical coordinates to Cartesian coordinates.