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.
 
 

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