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.
 
 

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