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.
 
 

532 lines
19 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, timedelta
  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.units import Angle
  22. from skyfield import almanac, eclipselib
  23. from numpy import pi
  24. from kosmorrolib.model import (
  25. Object,
  26. Event,
  27. Object,
  28. Star,
  29. Planet,
  30. get_aster,
  31. ASTERS,
  32. EARTH,
  33. )
  34. from kosmorrolib.dateutil import translate_to_timezone
  35. from kosmorrolib.enum import EventType, ObjectIdentifier, SeasonType, LunarEclipseType
  36. from kosmorrolib.exceptions import OutOfRangeDateError
  37. from kosmorrolib.core import get_timescale, get_skf_objects, flatten_list
  38. def _search_conjunctions_occultations(
  39. start_time: Time, end_time: Time, timezone: int
  40. ) -> [Event]:
  41. """Function to search conjunction.
  42. **Warning:** this is an internal function, not intended for use by end-developers.
  43. Will return MOON and VENUS opposition on 2021-06-12:
  44. >>> conjunction = _search_conjunctions_occultations(get_timescale().utc(2021, 6, 12), get_timescale().utc(2021, 6, 13), 0)
  45. >>> len(conjunction)
  46. 1
  47. >>> conjunction[0].objects
  48. [<Object type=SATELLITE name=MOON />, <Object type=PLANET name=VENUS />]
  49. Will return nothing if no conjunction happens:
  50. >>> _search_conjunctions_occultations(get_timescale().utc(2021, 6, 17),get_timescale().utc(2021, 6, 18), 0)
  51. []
  52. This function detects occultations too:
  53. >>> _search_conjunctions_occultations(get_timescale().utc(2021, 4, 17),get_timescale().utc(2021, 4, 18), 0)
  54. [<Event type=OCCULTATION objects=[<Object type=SATELLITE name=MOON />, <Object type=PLANET name=MARS />] start=2021-04-17 12:08:16.115650+00:00 end=None details=None />]
  55. """
  56. earth = get_skf_objects()["earth"]
  57. aster1 = None
  58. aster2 = None
  59. def is_in_conjunction(time: Time):
  60. earth_pos = earth.at(time)
  61. _, aster1_lon, _ = (
  62. earth_pos.observe(aster1.skyfield_object).apparent().ecliptic_latlon()
  63. )
  64. _, aster2_lon, _ = (
  65. earth_pos.observe(aster2.skyfield_object).apparent().ecliptic_latlon()
  66. )
  67. return ((aster1_lon.radians - aster2_lon.radians) / pi % 2.0).astype(
  68. "int8"
  69. ) == 0
  70. is_in_conjunction.rough_period = 60.0
  71. computed = []
  72. events = []
  73. for aster1 in ASTERS:
  74. # Ignore the Sun
  75. if isinstance(aster1, Star):
  76. continue
  77. for aster2 in ASTERS:
  78. if isinstance(aster2, Star) or aster2 == aster1 or aster2 in computed:
  79. continue
  80. times, is_conjs = find_discrete(start_time, end_time, is_in_conjunction)
  81. for i, time in enumerate(times):
  82. if is_conjs[i]:
  83. aster1_pos = (aster1.skyfield_object - earth).at(time)
  84. aster2_pos = (aster2.skyfield_object - earth).at(time)
  85. distance = aster1_pos.separation_from(aster2_pos).degrees
  86. if (
  87. distance - aster2.get_apparent_radius(time).degrees
  88. < aster1.get_apparent_radius(time).degrees
  89. ):
  90. occulting_aster = (
  91. [aster1, aster2]
  92. if aster1_pos.distance().km < aster2_pos.distance().km
  93. else [aster2, aster1]
  94. )
  95. events.append(
  96. Event(
  97. EventType.OCCULTATION,
  98. occulting_aster,
  99. translate_to_timezone(time.utc_datetime(), timezone),
  100. )
  101. )
  102. else:
  103. events.append(
  104. Event(
  105. EventType.CONJUNCTION,
  106. [aster1, aster2],
  107. translate_to_timezone(time.utc_datetime(), timezone),
  108. )
  109. )
  110. computed.append(aster1)
  111. return events
  112. def _search_oppositions(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  113. """Function to search oppositions.
  114. **Warning:** this is an internal function, not intended for use by end-developers.
  115. Will return Mars opposition on 2020-10-13:
  116. >>> oppositions = _search_oppositions(get_timescale().utc(2020, 10, 13), get_timescale().utc(2020, 10, 14), 0)
  117. >>> len(oppositions)
  118. 1
  119. >>> oppositions[0].objects[0]
  120. <Object type=PLANET name=MARS />
  121. Will return nothing if no opposition happens:
  122. >>> _search_oppositions(get_timescale().utc(2021, 3, 20), get_timescale().utc(2021, 3, 21), 0)
  123. []
  124. >>> _search_oppositions(get_timescale().utc(2022, 12, 24), get_timescale().utc(2022, 12, 25), 0)
  125. []
  126. """
  127. earth = get_skf_objects()["earth"]
  128. sun = get_skf_objects()["sun"]
  129. aster = None
  130. def is_oppositing(time: Time) -> [bool]:
  131. diff = get_angle(time)
  132. return diff > 180
  133. def get_angle(time: Time):
  134. earth_pos = earth.at(time)
  135. sun_pos = earth_pos.observe(
  136. sun
  137. ).apparent() # Never do this without eyes protection!
  138. aster_pos = earth_pos.observe(aster.skyfield_object).apparent()
  139. _, lon1, _ = sun_pos.ecliptic_latlon()
  140. _, lon2, _ = aster_pos.ecliptic_latlon()
  141. return lon1.degrees - lon2.degrees
  142. is_oppositing.rough_period = 1.0
  143. events = []
  144. for aster in ASTERS:
  145. if not isinstance(aster, Planet) or aster.identifier in [
  146. ObjectIdentifier.MERCURY,
  147. ObjectIdentifier.VENUS,
  148. ]:
  149. continue
  150. times, _ = find_discrete(start_time, end_time, is_oppositing)
  151. for time in times:
  152. if int(get_angle(time)) != 180:
  153. # If the angle is negative, then it is actually a false positive.
  154. # Just ignoring it.
  155. continue
  156. events.append(
  157. Event(
  158. EventType.OPPOSITION,
  159. [aster],
  160. translate_to_timezone(time.utc_datetime(), timezone),
  161. )
  162. )
  163. return events
  164. def _search_maximal_elongations(
  165. start_time: Time, end_time: Time, timezone: int
  166. ) -> [Event]:
  167. """Function to search oppositions.
  168. **Warning:** this is an internal function, not intended for use by end-developers.
  169. Will return Mercury maimum elogation for September 14, 2021:
  170. >>> get_events(date(2021, 9, 14))
  171. [<Event type=MAXIMAL_ELONGATION objects=[<Object type=PLANET name=MERCURY />] start=2021-09-14 04:13:46.664879+00:00 end=None details={'deg': 26.8} />]
  172. """
  173. earth = get_skf_objects()["earth"]
  174. sun = get_skf_objects()["sun"]
  175. def get_elongation(planet: Object):
  176. def f(time: Time):
  177. sun_pos = (sun - earth).at(time)
  178. aster_pos = (planet.skyfield_object - earth).at(time)
  179. separation = sun_pos.separation_from(aster_pos)
  180. return separation.degrees
  181. f.rough_period = 1.0
  182. return f
  183. events = []
  184. for identifier in [ObjectIdentifier.MERCURY, ObjectIdentifier.VENUS]:
  185. planet = get_aster(identifier)
  186. times, elongations = find_maxima(
  187. start_time,
  188. end_time,
  189. f=get_elongation(planet),
  190. epsilon=1.0 / 24 / 3600,
  191. num=12,
  192. )
  193. for i, time in enumerate(times):
  194. elongation = round(elongations[i], 1)
  195. events.append(
  196. Event(
  197. EventType.MAXIMAL_ELONGATION,
  198. [planet],
  199. translate_to_timezone(time.utc_datetime(), timezone),
  200. details={"deg": elongation},
  201. )
  202. )
  203. return events
  204. def _get_distance(to_aster: Object, from_aster: Object):
  205. def get_distance(time: Time):
  206. from_pos = from_aster.skyfield_object.at(time)
  207. to_pos = from_pos.observe(to_aster.skyfield_object)
  208. return to_pos.distance().km
  209. get_distance.rough_period = 1.0
  210. return get_distance
  211. def _search_apogee(to_aster: Object, from_aster: Object = EARTH) -> callable:
  212. """Search for moon apogee
  213. **Warning:** this is an internal function, not intended for use by end-developers.
  214. Get the moon apogee:
  215. >>> _search_apogee(ASTERS[1])(get_timescale().utc(2021, 6, 8), get_timescale().utc(2021, 6, 9), 0)
  216. [<Event type=APOGEE objects=[<Object type=SATELLITE name=MOON />] start=2021-06-08 02:39:40.165271+00:00 end=None details={'distance_km': 406211.04850197025} />]
  217. Get the Earth's apogee:
  218. >>> _search_apogee(EARTH, from_aster=ASTERS[0])(get_timescale().utc(2021, 7, 5), get_timescale().utc(2021, 7, 6), 0)
  219. [<Event type=APOGEE objects=[<Object type=PLANET name=EARTH />] start=2021-07-05 22:35:42.148792+00:00 end=None details={'distance_km': 152100521.91712126} />]
  220. """
  221. def f(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  222. events = []
  223. times, distances = find_maxima(
  224. start_time,
  225. end_time,
  226. f=_get_distance(to_aster, from_aster),
  227. epsilon=1.0 / 24 / 60,
  228. )
  229. for i, time in enumerate(times):
  230. events.append(
  231. Event(
  232. EventType.APOGEE,
  233. [to_aster],
  234. translate_to_timezone(time.utc_datetime(), timezone),
  235. details={"distance_km": distances[i]},
  236. )
  237. )
  238. return events
  239. return f
  240. def _search_perigee(aster: Object, from_aster: Object = EARTH) -> callable:
  241. """Search for moon perigee
  242. **Warning:** this is an internal function, not intended for use by end-developers.
  243. Get the moon perigee:
  244. >>> _search_perigee(ASTERS[1])(get_timescale().utc(2021, 5, 26), get_timescale().utc(2021, 5, 27), 0)
  245. [<Event type=PERIGEE objects=[<Object type=SATELLITE name=MOON />] start=2021-05-26 01:56:01.983455+00:00 end=None details={'distance_km': 357313.9680798693} />]
  246. Get the Earth's perigee:
  247. >>> _search_perigee(EARTH, from_aster=ASTERS[0])(get_timescale().utc(2021, 1, 2), get_timescale().utc(2021, 1, 3), 0)
  248. [<Event type=PERIGEE objects=[<Object type=PLANET name=EARTH />] start=2021-01-02 13:59:00.495905+00:00 end=None details={'distance_km': 147093166.1686309} />]
  249. """
  250. def f(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  251. events = []
  252. times, distances = find_minima(
  253. start_time,
  254. end_time,
  255. f=_get_distance(aster, from_aster),
  256. epsilon=1.0 / 24 / 60,
  257. )
  258. for i, time in enumerate(times):
  259. events.append(
  260. Event(
  261. EventType.PERIGEE,
  262. [aster],
  263. translate_to_timezone(time.utc_datetime(), timezone),
  264. details={"distance_km": distances[i]},
  265. )
  266. )
  267. return events
  268. return f
  269. def _search_earth_season_change(
  270. start_time: Time, end_time: Time, timezone: int
  271. ) -> [Event]:
  272. """Function to find earth season change event.
  273. **Warning:** this is an internal function, not intended for use by end-developers.
  274. Will return JUNE SOLSTICE on 2020/06/20:
  275. >>> season_change = _search_earth_season_change(get_timescale().utc(2020, 6, 20), get_timescale().utc(2020, 6, 21), 0)
  276. >>> len(season_change)
  277. 1
  278. >>> season_change[0].event_type
  279. <EventType.SEASON_CHANGE: 7>
  280. >>> season_change[0].details
  281. {'season': <SeasonType.JUNE_SOLSTICE: 1>}
  282. Will return nothing if there is no season change event in the period of time being calculated:
  283. >>> _search_earth_season_change(get_timescale().utc(2021, 6, 17), get_timescale().utc(2021, 6, 18), 0)
  284. []
  285. """
  286. events = []
  287. event_time, event_id = almanac.find_discrete(
  288. start_time, end_time, almanac.seasons(get_skf_objects())
  289. )
  290. if len(event_time) == 0:
  291. return []
  292. events.append(
  293. Event(
  294. EventType.SEASON_CHANGE,
  295. [],
  296. translate_to_timezone(event_time.utc_datetime()[0], timezone),
  297. details={"season": SeasonType(event_id[0])},
  298. )
  299. )
  300. return events
  301. def _search_lunar_eclipse(start_time: Time, end_time: Time, timezone: int) -> [Event]:
  302. """Function to detect lunar eclipses.
  303. **Warning:** this is an internal function, not intended for use by end-developers.
  304. Will return a total lunar eclipse for 2021-05-26:
  305. >>> _search_lunar_eclipse(get_timescale().utc(2021, 5, 26), get_timescale().utc(2021, 5, 27), 0)
  306. [<Event type=LUNAR_ECLIPSE objects=[<Object type=SATELLITE name=MOON />] start=2021-05-26 08:47:54.795821+00:00 end=2021-05-26 13:49:34.353411+00:00 details={'type': <LunarEclipseType.TOTAL: 2>, 'maximum': datetime.datetime(2021, 5, 26, 11, 18, 42, 328842, tzinfo=datetime.timezone.utc)} />]
  307. >>> _search_lunar_eclipse(get_timescale().utc(2019, 7, 16), get_timescale().utc(2019, 7, 17), 0)
  308. [<Event type=LUNAR_ECLIPSE objects=[<Object type=SATELLITE name=MOON />] start=2019-07-16 18:39:53.391337+00:00 end=2019-07-17 00:21:51.378940+00:00 details={'type': <LunarEclipseType.PARTIAL: 1>, 'maximum': datetime.datetime(2019, 7, 16, 21, 30, 44, 170096, tzinfo=datetime.timezone.utc)} />]
  309. >>> _search_lunar_eclipse(get_timescale().utc(2017, 2, 11), get_timescale().utc(2017, 2, 12), 0)
  310. [<Event type=LUNAR_ECLIPSE objects=[<Object type=SATELLITE name=MOON />] start=2017-02-10 22:02:59.016572+00:00 end=2017-02-11 03:25:07.627886+00:00 details={'type': <LunarEclipseType.PENUMBRAL: 0>, 'maximum': datetime.datetime(2017, 2, 11, 0, 43, 51, 793786, tzinfo=datetime.timezone.utc)} />]
  311. """
  312. moon = get_aster(ObjectIdentifier.MOON)
  313. events = []
  314. t, y, details = eclipselib.lunar_eclipses(start_time, end_time, get_skf_objects())
  315. for ti, yi in zip(t, y):
  316. penumbra_radius = Angle(radians=details["penumbra_radius_radians"][0])
  317. _, max_lon, _ = (
  318. EARTH.skyfield_object.at(ti)
  319. .observe(moon.skyfield_object)
  320. .apparent()
  321. .ecliptic_latlon()
  322. )
  323. def is_in_penumbra(time: Time):
  324. _, lon, _ = (
  325. EARTH.skyfield_object.at(time)
  326. .observe(moon.skyfield_object)
  327. .apparent()
  328. .ecliptic_latlon()
  329. )
  330. moon_radius = details["moon_radius_radians"]
  331. return (
  332. abs(max_lon.radians - lon.radians)
  333. < penumbra_radius.radians + moon_radius
  334. )
  335. is_in_penumbra.rough_period = 60.0
  336. search_start_time = get_timescale().from_datetime(
  337. start_time.utc_datetime() - timedelta(days=1)
  338. )
  339. search_end_time = get_timescale().from_datetime(
  340. end_time.utc_datetime() + timedelta(days=1)
  341. )
  342. eclipse_start, _ = find_discrete(search_start_time, ti, is_in_penumbra)
  343. eclipse_end, _ = find_discrete(ti, search_end_time, is_in_penumbra)
  344. events.append(
  345. Event(
  346. EventType.LUNAR_ECLIPSE,
  347. [moon],
  348. start_time=translate_to_timezone(
  349. eclipse_start[0].utc_datetime(), timezone
  350. ),
  351. end_time=translate_to_timezone(eclipse_end[0].utc_datetime(), timezone),
  352. details={
  353. "type": LunarEclipseType(yi),
  354. "maximum": translate_to_timezone(ti.utc_datetime(), timezone),
  355. },
  356. )
  357. )
  358. return events
  359. def get_events(for_date: date = date.today(), timezone: int = 0) -> [Event]:
  360. """Calculate and return a list of events for the given date, adjusted to the given timezone if any.
  361. Find events that happen on April 4th, 2020 (show hours in UTC):
  362. >>> get_events(date(2020, 4, 4))
  363. [<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 />]
  364. Find events that happen on April 4th, 2020 (show timezones in UTC+2):
  365. >>> get_events(date(2020, 4, 4), 2)
  366. [<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 />]
  367. Find events that happen on April 3rd, 2020 (show timezones in UTC-2):
  368. >>> get_events(date(2020, 4, 3), -2)
  369. [<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 />]
  370. If there is no events for the given date, then an empty list is returned:
  371. >>> get_events(date(2021, 4, 20))
  372. []
  373. Note that the events can only be found for a date range.
  374. Asking for the events with an out of range date will result in an exception:
  375. >>> get_events(date(1000, 1, 1))
  376. Traceback (most recent call last):
  377. ...
  378. kosmorrolib.exceptions.OutOfRangeDateError: The date must be between 1899-07-28 and 2053-10-08
  379. :param for_date: the date for which the events must be calculated
  380. :param timezone: the timezone to adapt the results to. If not given, defaults to 0.
  381. :return: a list of events found for the given date.
  382. """
  383. start_time = get_timescale().utc(
  384. for_date.year, for_date.month, for_date.day, -timezone
  385. )
  386. end_time = get_timescale().utc(
  387. for_date.year, for_date.month, for_date.day + 1, -timezone
  388. )
  389. try:
  390. found_events = []
  391. for fun in [
  392. _search_oppositions,
  393. _search_conjunctions_occultations,
  394. _search_maximal_elongations,
  395. _search_apogee(ASTERS[1]),
  396. _search_perigee(ASTERS[1]),
  397. _search_apogee(EARTH, from_aster=ASTERS[0]),
  398. _search_perigee(EARTH, from_aster=ASTERS[0]),
  399. _search_earth_season_change,
  400. _search_lunar_eclipse,
  401. ]:
  402. found_events.append(fun(start_time, end_time, timezone))
  403. return sorted(flatten_list(found_events), key=lambda event: event.start_time)
  404. except EphemerisRangeError as error:
  405. start_date = translate_to_timezone(error.start_time.utc_datetime(), timezone)
  406. end_date = translate_to_timezone(error.end_time.utc_datetime(), timezone)
  407. start_date = date(start_date.year, start_date.month, start_date.day)
  408. end_date = date(end_date.year, end_date.month, end_date.day)
  409. raise OutOfRangeDateError(start_date, end_date) from error