476 righe
16 KiB

  1. #!/usr/bin/env python3
  2. # Kosmorro - Compute The Next Ephemerides
  3. # Copyright (C) 2019 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 abc import ABC, abstractmethod
  18. import datetime
  19. import json
  20. import os
  21. import tempfile
  22. import subprocess
  23. import locale
  24. import shutil
  25. from pathlib import Path
  26. from babel.dates import format_date, format_time
  27. from tabulate import tabulate
  28. from termcolor import colored
  29. from kosmorrolib import AsterEphemerides, Event, EventType
  30. from kosmorrolib.model import ASTERS, MoonPhase
  31. from .i18n.utils import _
  32. from .i18n import strings
  33. from .exceptions import (
  34. CompileError,
  35. UnavailableFeatureError as KosmorroUnavailableFeatureError,
  36. )
  37. from .debug import debug_print
  38. from .utils import KOSMORRO_VERSION
  39. class Dumper(ABC):
  40. ephemerides: [AsterEphemerides]
  41. moon_phase: MoonPhase
  42. events: [Event]
  43. date: datetime.date
  44. timezone: int
  45. with_colors: bool
  46. show_graph: bool
  47. def __init__(
  48. self,
  49. ephemerides: [AsterEphemerides],
  50. moon_phase: MoonPhase,
  51. events: [Event],
  52. date: datetime.date,
  53. timezone: int,
  54. with_colors: bool,
  55. show_graph: bool,
  56. ):
  57. self.ephemerides = ephemerides
  58. self.moon_phase = moon_phase
  59. self.events = events
  60. self.date = date
  61. self.timezone = timezone
  62. self.with_colors = with_colors
  63. self.show_graph = show_graph
  64. def get_date_as_string(self, capitalized: bool = False) -> str:
  65. date = format_date(self.date, "full", locale.getlocale()[0])
  66. if capitalized:
  67. return "".join([date[0].upper(), date[1:]])
  68. return date
  69. def __str__(self):
  70. return self.to_string()
  71. @abstractmethod
  72. def to_string(self):
  73. pass
  74. @staticmethod
  75. def is_file_output_needed() -> bool:
  76. return False
  77. class JsonDumper(Dumper):
  78. SUPPORTED_EVENTS = [
  79. EventType.OPPOSITION,
  80. EventType.CONJUNCTION,
  81. EventType.OCCULTATION,
  82. EventType.MAXIMAL_ELONGATION,
  83. EventType.PERIGEE,
  84. EventType.APOGEE,
  85. ]
  86. def to_string(self):
  87. return json.dumps(
  88. {
  89. "ephemerides": [
  90. ephemeris.serialize() for ephemeris in self.ephemerides
  91. ],
  92. "moon_phase": self.moon_phase.serialize(),
  93. "events": list(self.get_events()),
  94. },
  95. indent=4,
  96. )
  97. def get_events(self) -> [{str: any}]:
  98. for event in self.events:
  99. if event.event_type not in self.SUPPORTED_EVENTS:
  100. continue
  101. yield event.serialize()
  102. class TextDumper(Dumper):
  103. def to_string(self):
  104. text = [self.style(self.get_date_as_string(capitalized=True), "h1")]
  105. if len(self.ephemerides) > 0:
  106. text.append(self.stringify_ephemerides())
  107. text.append(self.get_moon(self.moon_phase))
  108. if len(self.events) > 0:
  109. events = self.get_events(self.events)
  110. if events.strip("\n") != "":
  111. text.append(
  112. "\n".join(
  113. [
  114. self.style(_("Expected events:"), "h2"),
  115. events,
  116. ]
  117. )
  118. )
  119. if self.timezone == 0:
  120. text.append(self.style(_("Note: All the hours are given in UTC."), "em"))
  121. else:
  122. tz_offset = str(self.timezone)
  123. if self.timezone > 0:
  124. tz_offset = "".join(["+", tz_offset])
  125. text.append(
  126. self.style(
  127. _(
  128. "Note: All the hours are given in the UTC{offset} timezone."
  129. ).format(offset=tz_offset),
  130. "em",
  131. )
  132. )
  133. return "\n\n".join(text)
  134. def style(self, text: str, tag: str) -> str:
  135. if not self.with_colors:
  136. return text
  137. styles = {
  138. "h1": lambda t: colored(t, "green", attrs=["bold"]),
  139. "h2": lambda t: colored(t, "magenta", attrs=["bold"]),
  140. "th": lambda t: colored(t, attrs=["bold"]),
  141. "strong": lambda t: colored(t, attrs=["bold"]),
  142. "em": lambda t: colored(t, attrs=["dark"]),
  143. }
  144. return styles.get(tag, lambda t: t)(text)
  145. def stringify_ephemerides(self) -> str:
  146. data = []
  147. for ephemeris in self.ephemerides:
  148. object_name = strings.from_object(ephemeris.object.identifier)
  149. if object_name is None:
  150. continue
  151. name = self.style(object_name, "th")
  152. planet_rise = (
  153. "-"
  154. if ephemeris.rise_time is None
  155. else format_time(ephemeris.rise_time, "short")
  156. )
  157. planet_culmination = (
  158. "-"
  159. if ephemeris.culmination_time is None
  160. else format_time(ephemeris.culmination_time, "short")
  161. )
  162. planet_set = (
  163. "-"
  164. if ephemeris.set_time is None
  165. else format_time(ephemeris.set_time, "short")
  166. )
  167. data.append([name, planet_rise, planet_culmination, planet_set])
  168. return tabulate(
  169. data,
  170. headers=[
  171. self.style(_("Object"), "th"),
  172. self.style(_("Rise time"), "th"),
  173. self.style(_("Culmination time"), "th"),
  174. self.style(_("Set time"), "th"),
  175. ],
  176. tablefmt="simple",
  177. stralign="center",
  178. colalign=("left",),
  179. )
  180. def get_events(self, events: [Event]) -> str:
  181. data = []
  182. for event in events:
  183. description = strings.from_event(event)
  184. if description is None:
  185. continue
  186. data.append(
  187. [
  188. self.style(format_time(event.start_time, "short"), "th"),
  189. description,
  190. ]
  191. )
  192. return tabulate(data, tablefmt="plain", stralign="left")
  193. def get_moon(self, moon_phase: MoonPhase) -> str:
  194. if moon_phase is None:
  195. return _("Moon phase is unavailable for this date.")
  196. current_moon_phase = " ".join(
  197. [
  198. self.style(_("Moon phase:"), "strong"),
  199. strings.from_moon_phase(moon_phase.phase_type),
  200. ]
  201. )
  202. new_moon_phase = _(
  203. "{next_moon_phase} on {next_moon_phase_date} at {next_moon_phase_time}"
  204. ).format(
  205. next_moon_phase=_(strings.from_moon_phase(moon_phase.get_next_phase())),
  206. next_moon_phase_date=format_date(
  207. moon_phase.next_phase_date, "full", locale.getlocale()[0]
  208. ),
  209. next_moon_phase_time=format_time(
  210. moon_phase.next_phase_date, "short", locale=locale.getlocale()[0]
  211. ),
  212. )
  213. return "\n".join([current_moon_phase, new_moon_phase])
  214. class LatexDumper(Dumper):
  215. def to_string(self):
  216. template_path = os.path.join(
  217. os.path.abspath(os.path.dirname(__file__)),
  218. "assets",
  219. "latex",
  220. "template.tex",
  221. )
  222. with open(template_path, mode="r") as file:
  223. template = file.read()
  224. return self._make_document(template)
  225. def _make_document(self, template: str) -> str:
  226. kosmorro_logo_path = os.path.join(
  227. os.path.abspath(os.path.dirname(__file__)),
  228. "assets",
  229. "png",
  230. "kosmorro-logo.png",
  231. )
  232. moon_phase_graphics = os.path.join(
  233. os.path.abspath(os.path.dirname(__file__)),
  234. "assets",
  235. "moonphases",
  236. "png",
  237. ".".join(
  238. [self.moon_phase.phase_type.name.lower().replace("_", "-"), "png"]
  239. ),
  240. )
  241. document = template
  242. if self.ephemerides is None:
  243. document = self._remove_section(document, "ephemerides")
  244. if len(self.events) == 0:
  245. document = self._remove_section(document, "events")
  246. document = self.add_strings(document, kosmorro_logo_path, moon_phase_graphics)
  247. if self.show_graph:
  248. # The graphephemerides environment beginning tag must end with a percent symbol to ensure
  249. # that no extra space will interfere with the graph.
  250. document = document.replace(
  251. r"\begin{ephemerides}", r"\begin{graphephemerides}%"
  252. ).replace(r"\end{ephemerides}", r"\end{graphephemerides}")
  253. return document
  254. def add_strings(
  255. self, document: str, kosmorro_logo_path: str, moon_phase_graphics: str
  256. ) -> str:
  257. document = document.replace("+++KOSMORRO-VERSION+++", KOSMORRO_VERSION)
  258. document = document.replace("+++KOSMORRO-LOGO+++", kosmorro_logo_path)
  259. document = document.replace("+++DOCUMENT-TITLE+++", _("Overview of your sky"))
  260. document = document.replace(
  261. "+++DOCUMENT-DATE+++", self.get_date_as_string(capitalized=True)
  262. )
  263. document = document.replace(
  264. "+++INTRODUCTION+++",
  265. "\n\n".join(
  266. [
  267. _(
  268. "This document summarizes the ephemerides and the events of {date}. "
  269. "It aims to help you to prepare your observation session. "
  270. "All the hours are given in {timezone}."
  271. ).format(
  272. date=self.get_date_as_string(),
  273. timezone=(
  274. "UTC+%d" % self.timezone if self.timezone != 0 else "UTC"
  275. ),
  276. ),
  277. _(
  278. "Don't forget to check the weather forecast before you go out with your equipment."
  279. ),
  280. ]
  281. ),
  282. )
  283. document = document.replace(
  284. "+++SECTION-EPHEMERIDES+++", _("Ephemerides of the day")
  285. )
  286. document = document.replace("+++EPHEMERIDES-OBJECT+++", _("Object"))
  287. document = document.replace("+++EPHEMERIDES-RISE-TIME+++", _("Rise time"))
  288. document = document.replace(
  289. "+++EPHEMERIDES-CULMINATION-TIME+++", _("Culmination time")
  290. )
  291. document = document.replace("+++EPHEMERIDES-SET-TIME+++", _("Set time"))
  292. document = document.replace("+++EPHEMERIDES+++", self._make_ephemerides())
  293. document = document.replace("+++GRAPH_LABEL_HOURS+++", _("hours"))
  294. document = document.replace("+++MOON-PHASE-GRAPHICS+++", moon_phase_graphics)
  295. document = document.replace("+++CURRENT-MOON-PHASE-TITLE+++", _("Moon phase:"))
  296. document = document.replace(
  297. "+++CURRENT-MOON-PHASE+++",
  298. strings.from_moon_phase(self.moon_phase.phase_type),
  299. )
  300. document = document.replace("+++SECTION-EVENTS+++", _("Expected events"))
  301. document = document.replace("+++EVENTS+++", self._make_events())
  302. for aster in ASTERS:
  303. object_name = strings.from_object(aster.identifier)
  304. if object_name is None:
  305. continue
  306. document = document.replace(
  307. "+++ASTER_%s+++" % aster.identifier.name,
  308. object_name,
  309. )
  310. return document
  311. def _make_ephemerides(self) -> str:
  312. latex = []
  313. graph_y_component = 18
  314. if self.ephemerides is not None:
  315. for ephemeris in self.ephemerides:
  316. aster_rise = (
  317. "-"
  318. if ephemeris.rise_time is None
  319. else format_time(ephemeris.rise_time, "short")
  320. )
  321. aster_culmination = (
  322. "-"
  323. if ephemeris.culmination_time is None
  324. else format_time(ephemeris.culmination_time, "short")
  325. )
  326. aster_set = (
  327. "-"
  328. if ephemeris.set_time is None
  329. else format_time(ephemeris.set_time, "short")
  330. )
  331. if not self.show_graph:
  332. object_name = strings.from_object(ephemeris.object.identifier)
  333. if object_name is not None:
  334. latex.append(
  335. r"\object{%s}{%s}{%s}{%s}"
  336. % (
  337. object_name,
  338. aster_rise,
  339. aster_culmination,
  340. aster_set,
  341. )
  342. )
  343. else:
  344. if ephemeris.rise_time is not None:
  345. raise_hour = ephemeris.rise_time.hour
  346. raise_minute = ephemeris.rise_time.minute
  347. else:
  348. raise_hour = raise_minute = 0
  349. aster_rise = ""
  350. if ephemeris.set_time is not None:
  351. set_hour = ephemeris.set_time.hour
  352. set_minute = ephemeris.set_time.minute
  353. else:
  354. set_hour = 24
  355. set_minute = 0
  356. aster_set = ""
  357. sets_after_end = set_hour > raise_hour
  358. if not sets_after_end:
  359. latex.append(
  360. r"\graphobject{%d}{gray}{0}{0}{%d}{%d}{}{%s}"
  361. % (graph_y_component, set_hour, set_minute, aster_set)
  362. )
  363. set_hour = 24
  364. set_minute = 0
  365. latex.append(
  366. r"\graphobject{%d}{gray}{%d}{%d}{%d}{%d}{%s}{%s}"
  367. % (
  368. graph_y_component,
  369. raise_hour,
  370. raise_minute,
  371. set_hour,
  372. set_minute,
  373. aster_rise,
  374. aster_set if sets_after_end else "",
  375. )
  376. )
  377. graph_y_component -= 2
  378. return "".join(latex)
  379. def _make_events(self) -> str:
  380. latex = []
  381. for event in self.events:
  382. event_name = strings.from_event(event)
  383. if event_name is None:
  384. continue
  385. latex.append(
  386. r"\event{%s}{%s}" % (format_time(event.start_time, "short"), event_name)
  387. )
  388. return "".join(latex)
  389. @staticmethod
  390. def _remove_section(document: str, section: str):
  391. begin_section_tag = "%%%%%% BEGIN-%s-SECTION" % section.upper()
  392. end_section_tag = "%%%%%% END-%s-SECTION" % section.upper()
  393. document = document.split("\n")
  394. new_document = []
  395. ignore_line = False
  396. for line in document:
  397. if begin_section_tag in line or end_section_tag in line:
  398. ignore_line = not ignore_line
  399. continue
  400. if ignore_line:
  401. continue
  402. new_document.append(line)
  403. return "\n".join(new_document)