A library that computes the ephemerides.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

304 lines
9.6 KiB

  1. #!/usr/bin/env python3
  2. from datetime import date
  3. from skyfield.errors import EphemerisRangeError
  4. from skyfield.timelib import Time
  5. from skyfield.searchlib import find_discrete, find_maxima, find_minima
  6. from numpy import pi
  7. from .model import Event, Star, Planet, ASTERS
  8. from .dateutil import translate_to_timezone
  9. from .enum import EventType, ObjectIdentifier
  10. from .exceptions import OutOfRangeDateError
  11. from .core import get_timescale, get_skf_objects, flatten_list
  12. def _search_conjunction(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  13. earth = get_skf_objects()["earth"]
  14. aster1 = None
  15. aster2 = None
  16. def is_in_conjunction(time: Time):
  17. earth_pos = earth.at(time)
  18. _, aster1_lon, _ = (
  19. earth_pos.observe(aster1.get_skyfield_object()).apparent().ecliptic_latlon()
  20. )
  21. _, aster2_lon, _ = (
  22. earth_pos.observe(aster2.get_skyfield_object()).apparent().ecliptic_latlon()
  23. )
  24. return ((aster1_lon.radians - aster2_lon.radians) / pi % 2.0).astype(
  25. "int8"
  26. ) == 0
  27. is_in_conjunction.rough_period = 60.0
  28. computed = []
  29. conjunctions = []
  30. for aster1 in ASTERS:
  31. # Ignore the Sun
  32. if isinstance(aster1, Star):
  33. continue
  34. for aster2 in ASTERS:
  35. if isinstance(aster2, Star) or aster2 == aster1 or aster2 in computed:
  36. continue
  37. times, is_conjs = find_discrete(start_time, end_time, is_in_conjunction)
  38. for i, time in enumerate(times):
  39. if is_conjs[i]:
  40. aster1_pos = (aster1.get_skyfield_object() - earth).at(time)
  41. aster2_pos = (aster2.get_skyfield_object() - earth).at(time)
  42. distance = aster1_pos.separation_from(aster2_pos).degrees
  43. if distance - aster2.get_apparent_radius(
  44. time, earth
  45. ) < aster1.get_apparent_radius(time, earth):
  46. occulting_aster = (
  47. [aster1, aster2]
  48. if aster1_pos.distance().km < aster2_pos.distance().km
  49. else [aster2, aster1]
  50. )
  51. conjunctions.append(
  52. Event(
  53. EventType.OCCULTATION,
  54. occulting_aster,
  55. translate_to_timezone(time.utc_datetime(), timezone),
  56. )
  57. )
  58. else:
  59. conjunctions.append(
  60. Event(
  61. EventType.CONJUNCTION,
  62. [aster1, aster2],
  63. translate_to_timezone(time.utc_datetime(), timezone),
  64. )
  65. )
  66. computed.append(aster1)
  67. return conjunctions
  68. def _search_oppositions(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  69. """Function to search oppositions.
  70. **Warning:** this is an internal function, not intended for use by end-developers.
  71. Will return Mars opposition on 2020-10-13:
  72. >>> oppositions = _search_oppositions(get_timescale().utc(2020, 10, 13), get_timescale().utc(2020, 10, 14), 0)
  73. >>> len(oppositions)
  74. 1
  75. >>> oppositions[0].objects[0]
  76. <Object type=PLANET name=MARS />
  77. Will return nothing if no opposition happens:
  78. >>> _search_oppositions(get_timescale().utc(2021, 3, 20), get_timescale().utc(2021, 3, 21), 0)
  79. []
  80. """
  81. earth = get_skf_objects()["earth"]
  82. sun = get_skf_objects()["sun"]
  83. aster = None
  84. def is_oppositing(time: Time) -> [bool]:
  85. diff = get_angle(time)
  86. return diff > 180
  87. def get_angle(time: Time):
  88. earth_pos = earth.at(time)
  89. sun_pos = earth_pos.observe(
  90. sun
  91. ).apparent() # Never do this without eyes protection!
  92. aster_pos = earth_pos.observe(get_skf_objects()[aster.skyfield_name]).apparent()
  93. _, lon1, _ = sun_pos.ecliptic_latlon()
  94. _, lon2, _ = aster_pos.ecliptic_latlon()
  95. return lon1.degrees - lon2.degrees
  96. is_oppositing.rough_period = 1.0
  97. events = []
  98. for aster in ASTERS:
  99. if not isinstance(aster, Planet) or aster.identifier in [
  100. ObjectIdentifier.MERCURY,
  101. ObjectIdentifier.VENUS,
  102. ]:
  103. continue
  104. times, _ = find_discrete(start_time, end_time, is_oppositing)
  105. for time in times:
  106. if get_angle(time) < 0:
  107. # If the angle is negative, then it is actually a false positive.
  108. # Just ignoring it.
  109. continue
  110. events.append(
  111. Event(
  112. EventType.OPPOSITION,
  113. [aster],
  114. translate_to_timezone(time.utc_datetime(), timezone),
  115. )
  116. )
  117. return events
  118. def _search_maximal_elongations(
  119. start_time: Time, end_time: Time, timezone: int
  120. ) -> [Event]:
  121. earth = get_skf_objects()["earth"]
  122. sun = get_skf_objects()["sun"]
  123. aster = None
  124. def get_elongation(time: Time):
  125. sun_pos = (sun - earth).at(time)
  126. aster_pos = (aster.get_skyfield_object() - earth).at(time)
  127. separation = sun_pos.separation_from(aster_pos)
  128. return separation.degrees
  129. get_elongation.rough_period = 1.0
  130. events = []
  131. for aster in ASTERS:
  132. if aster.skyfield_name not in ["MERCURY", "VENUS"]:
  133. continue
  134. times, elongations = find_maxima(
  135. start_time, end_time, f=get_elongation, epsilon=1.0 / 24 / 3600, num=12
  136. )
  137. for i, time in enumerate(times):
  138. elongation = elongations[i]
  139. events.append(
  140. Event(
  141. EventType.MAXIMAL_ELONGATION,
  142. [aster],
  143. translate_to_timezone(time.utc_datetime(), timezone),
  144. details="{:.3n}°".format(elongation),
  145. )
  146. )
  147. return events
  148. def _get_moon_distance():
  149. earth = get_skf_objects()["earth"]
  150. moon = get_skf_objects()["moon"]
  151. def get_distance(time: Time):
  152. earth_pos = earth.at(time)
  153. moon_pos = earth_pos.observe(moon).apparent()
  154. return moon_pos.distance().au
  155. get_distance.rough_period = 1.0
  156. return get_distance
  157. def _search_moon_apogee(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  158. moon = ASTERS[1]
  159. events = []
  160. times, _ = find_maxima(
  161. start_time, end_time, f=_get_moon_distance(), epsilon=1.0 / 24 / 60
  162. )
  163. for time in times:
  164. events.append(
  165. Event(
  166. EventType.MOON_APOGEE,
  167. [moon],
  168. translate_to_timezone(time.utc_datetime(), timezone),
  169. )
  170. )
  171. return events
  172. def _search_moon_perigee(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  173. moon = ASTERS[1]
  174. events = []
  175. times, _ = find_minima(
  176. start_time, end_time, f=_get_moon_distance(), epsilon=1.0 / 24 / 60
  177. )
  178. for time in times:
  179. events.append(
  180. Event(
  181. EventType.MOON_PERIGEE,
  182. [moon],
  183. translate_to_timezone(time.utc_datetime(), timezone),
  184. )
  185. )
  186. return events
  187. def get_events(for_date: date = date.today(), timezone: int = 0) -> [Event]:
  188. """Calculate and return a list of events for the given date, adjusted to the given timezone if any.
  189. Find events that happen on April 4th, 2020 (show hours in UTC):
  190. >>> get_events(date(2020, 4, 4))
  191. [<Event type=CONJUNCTION objects=[<Object type=PLANET name=MERCURY />, <Object type=PLANET name=NEPTUNE />] start=2020-04-04 01:14:39.063308+00:00 end=None details=None />]
  192. Find events that happen on April 4th, 2020 (show timezones in UTC+2):
  193. >>> get_events(date(2020, 4, 4), 2)
  194. [<Event type=CONJUNCTION objects=[<Object type=PLANET name=MERCURY />, <Object type=PLANET name=NEPTUNE />] start=2020-04-04 03:14:39.063267+02:00 end=None details=None />]
  195. Find events that happen on April 3rd, 2020 (show timezones in UTC-2):
  196. >>> get_events(date(2020, 4, 3), -2)
  197. [<Event type=CONJUNCTION objects=[<Object type=PLANET name=MERCURY />, <Object type=PLANET name=NEPTUNE />] start=2020-04-03 23:14:39.063388-02:00 end=None details=None />]
  198. If there is no events for the given date, then an empty list is returned:
  199. >>> get_events(date(2021, 3, 20))
  200. []
  201. :param for_date: the date for which the events must be calculated
  202. :param timezone: the timezone to adapt the results to. If not given, defaults to 0.
  203. :return: a list of events found for the given date.
  204. """
  205. start_time = get_timescale().utc(
  206. for_date.year, for_date.month, for_date.day, -timezone
  207. )
  208. end_time = get_timescale().utc(
  209. for_date.year, for_date.month, for_date.day + 1, -timezone
  210. )
  211. try:
  212. found_events = []
  213. for fun in [
  214. _search_oppositions,
  215. _search_conjunction,
  216. _search_maximal_elongations,
  217. _search_moon_apogee,
  218. _search_moon_perigee,
  219. ]:
  220. found_events.append(fun(start_time, end_time, timezone))
  221. return sorted(flatten_list(found_events), key=lambda event: event.start_time)
  222. except EphemerisRangeError as error:
  223. start_date = translate_to_timezone(error.start_time.utc_datetime(), timezone)
  224. end_date = translate_to_timezone(error.end_time.utc_datetime(), timezone)
  225. start_date = date(start_date.year, start_date.month, start_date.day)
  226. end_date = date(end_date.year, end_date.month, end_date.day)
  227. raise OutOfRangeDateError(start_date, end_date) from error