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.
 
 

378 lines
12 KiB

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