No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

398 líneas
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. from pathlib import Path
  22. from tabulate import tabulate
  23. from numpy import int64
  24. from termcolor import colored
  25. from .data import ASTERS, Object, AsterEphemerides, MoonPhase, Event
  26. from .i18n import _, FULL_DATE_FORMAT, SHORT_DATETIME_FORMAT, TIME_FORMAT
  27. from .version import VERSION
  28. from .exceptions import UnavailableFeatureError
  29. try:
  30. from latex import build_pdf
  31. except ImportError:
  32. build_pdf = None
  33. class Dumper(ABC):
  34. def __init__(self, ephemerides: [AsterEphemerides] = None, moon_phase: MoonPhase = None, events: [Event] = None,
  35. date: datetime.date = datetime.date.today(), timezone: int = 0, with_colors: bool = True,
  36. show_graph: bool = False):
  37. self.ephemerides = ephemerides
  38. self.moon_phase = moon_phase
  39. self.events = events
  40. self.date = date
  41. self.timezone = timezone
  42. self.with_colors = with_colors
  43. self.show_graph = show_graph
  44. def get_date_as_string(self, capitalized: bool = False) -> str:
  45. date = self.date.strftime(FULL_DATE_FORMAT)
  46. if capitalized:
  47. return ''.join([date[0].upper(), date[1:]])
  48. return date
  49. def __str__(self):
  50. return self.to_string()
  51. @abstractmethod
  52. def to_string(self):
  53. pass
  54. @staticmethod
  55. def is_file_output_needed() -> bool:
  56. return False
  57. class JsonDumper(Dumper):
  58. def to_string(self):
  59. return json.dumps({
  60. 'ephemerides': [ephemeris.serialize() for ephemeris in self.ephemerides],
  61. 'moon_phase': self.moon_phase.serialize(),
  62. 'events': [event.serialize() for event in self.events]
  63. }, indent=4)
  64. @staticmethod
  65. def _json_default(obj):
  66. # Fixes the "TypeError: Object of type int64 is not JSON serializable"
  67. # See https://stackoverflow.com/a/50577730
  68. if isinstance(obj, int64):
  69. return int(obj)
  70. if isinstance(obj, datetime.datetime):
  71. return obj.isoformat()
  72. if isinstance(obj, Object):
  73. obj = obj.__dict__
  74. obj.pop('skyfield_name')
  75. obj.pop('radius')
  76. obj['object'] = obj.pop('name')
  77. obj['details'] = obj.pop('ephemerides')
  78. return obj
  79. if isinstance(obj, AsterEphemerides):
  80. return obj.__dict__
  81. if isinstance(obj, MoonPhase):
  82. moon_phase = obj.__dict__
  83. moon_phase['phase'] = moon_phase.pop('identifier')
  84. moon_phase['date'] = moon_phase.pop('time')
  85. return moon_phase
  86. if isinstance(obj, Event):
  87. event = obj.__dict__
  88. event['objects'] = [object.name for object in event['objects']]
  89. return event
  90. raise TypeError('Object of type "%s" could not be integrated in the JSON' % str(type(obj)))
  91. class TextDumper(Dumper):
  92. def to_string(self):
  93. text = [self.style(self.get_date_as_string(capitalized=True), 'h1')]
  94. if self.ephemerides is not None:
  95. text.append(self.stringify_ephemerides())
  96. text.append(self.get_moon(self.moon_phase))
  97. if len(self.events) > 0:
  98. text.append('\n'.join([self.style(_('Expected events:'), 'h2'),
  99. self.get_events(self.events)]))
  100. if self.timezone == 0:
  101. text.append(self.style(_('Note: All the hours are given in UTC.'), 'em'))
  102. else:
  103. tz_offset = str(self.timezone)
  104. if self.timezone > 0:
  105. tz_offset = ''.join(['+', tz_offset])
  106. text.append(self.style(_('Note: All the hours are given in the UTC{offset} timezone.').format(
  107. offset=tz_offset), 'em'))
  108. return '\n\n'.join(text)
  109. def style(self, text: str, tag: str) -> str:
  110. if not self.with_colors:
  111. return text
  112. styles = {
  113. 'h1': lambda t: colored(t, 'yellow', attrs=['bold']),
  114. 'h2': lambda t: colored(t, 'magenta', attrs=['bold']),
  115. 'th': lambda t: colored(t, 'white', attrs=['bold']),
  116. 'strong': lambda t: colored(t, attrs=['bold']),
  117. 'em': lambda t: colored(t, attrs=['dark'])
  118. }
  119. return styles[tag](text)
  120. def stringify_ephemerides(self) -> str:
  121. data = []
  122. for ephemeris in self.ephemerides:
  123. name = self.style(ephemeris.object.name, 'th')
  124. if ephemeris.rise_time is not None:
  125. time_fmt = TIME_FORMAT if ephemeris.rise_time.day == self.date.day else SHORT_DATETIME_FORMAT
  126. planet_rise = ephemeris.rise_time.strftime(time_fmt)
  127. else:
  128. planet_rise = '-'
  129. if ephemeris.culmination_time is not None:
  130. time_fmt = TIME_FORMAT if ephemeris.culmination_time.day == self.date.day \
  131. else SHORT_DATETIME_FORMAT
  132. planet_culmination = ephemeris.culmination_time.strftime(time_fmt)
  133. else:
  134. planet_culmination = '-'
  135. if ephemeris.set_time is not None:
  136. time_fmt = TIME_FORMAT if ephemeris.set_time.day == self.date.day else SHORT_DATETIME_FORMAT
  137. planet_set = ephemeris.set_time.strftime(time_fmt)
  138. else:
  139. planet_set = '-'
  140. data.append([name, planet_rise, planet_culmination, planet_set])
  141. return tabulate(data, headers=[self.style(_('Object'), 'th'),
  142. self.style(_('Rise time'), 'th'),
  143. self.style(_('Culmination time'), 'th'),
  144. self.style(_('Set time'), 'th')],
  145. tablefmt='simple', stralign='center', colalign=('left',))
  146. def get_events(self, events: [Event]) -> str:
  147. data = []
  148. for event in events:
  149. time_fmt = TIME_FORMAT if event.start_time.day == self.date.day else SHORT_DATETIME_FORMAT
  150. data.append([self.style(event.start_time.strftime(time_fmt), 'th'),
  151. event.get_description()])
  152. return tabulate(data, tablefmt='plain', stralign='left')
  153. def get_moon(self, moon_phase: MoonPhase) -> str:
  154. if moon_phase is None:
  155. return _('Moon phase is unavailable for this date.')
  156. current_moon_phase = ' '.join([self.style(_('Moon phase:'), 'strong'), moon_phase.get_phase()])
  157. new_moon_phase = _('{next_moon_phase} on {next_moon_phase_date} at {next_moon_phase_time}').format(
  158. next_moon_phase=moon_phase.get_next_phase_name(),
  159. next_moon_phase_date=moon_phase.next_phase_date.strftime(FULL_DATE_FORMAT),
  160. next_moon_phase_time=moon_phase.next_phase_date.strftime(TIME_FORMAT)
  161. )
  162. return '\n'.join([current_moon_phase, new_moon_phase])
  163. class _LatexDumper(Dumper):
  164. def to_string(self):
  165. template_path = os.path.join(os.path.abspath(os.path.dirname(__file__)),
  166. 'assets', 'pdf', 'template.tex')
  167. with open(template_path, mode='r') as file:
  168. template = file.read()
  169. return self._make_document(template)
  170. def _make_document(self, template: str) -> str:
  171. kosmorro_logo_path = os.path.join(os.path.abspath(os.path.dirname(__file__)),
  172. 'assets', 'png', 'kosmorro-logo.png')
  173. if self.moon_phase is None:
  174. self.moon_phase = MoonPhase('UNKNOWN')
  175. moon_phase_graphics = os.path.join(os.path.abspath(os.path.dirname(__file__)),
  176. 'assets', 'moonphases', 'png',
  177. '.'.join([self.moon_phase.identifier.lower().replace('_', '-'),
  178. 'png']))
  179. document = template
  180. if self.ephemerides is None:
  181. document = self._remove_section(document, 'ephemerides')
  182. if len(self.events) == 0:
  183. document = self._remove_section(document, 'events')
  184. document = self.add_strings(document, kosmorro_logo_path, moon_phase_graphics)
  185. if self.show_graph:
  186. # The graphephemerides environment beginning tag must end with a percent symbol to ensure
  187. # that no extra space will interfere with the graph.
  188. document = document.replace(r'\begin{ephemerides}', r'\begin{graphephemerides}%')\
  189. .replace(r'\end{ephemerides}', r'\end{graphephemerides}')
  190. return document
  191. def add_strings(self, document, kosmorro_logo_path, moon_phase_graphics) -> str:
  192. document = document \
  193. .replace('+++KOSMORRO-VERSION+++', VERSION) \
  194. .replace('+++KOSMORRO-LOGO+++', kosmorro_logo_path) \
  195. .replace('+++DOCUMENT-TITLE+++', _('A Summary of your Sky')) \
  196. .replace('+++DOCUMENT-DATE+++', self.get_date_as_string(capitalized=True)) \
  197. .replace('+++INTRODUCTION+++',
  198. '\n\n'.join([
  199. _("This document summarizes the ephemerides and the events of {date}. "
  200. "It aims to help you to prepare your observation session. "
  201. "All the hours are given in {timezone}.").format(
  202. date=self.get_date_as_string(),
  203. timezone='UTC+%d' % self.timezone if self.timezone != 0 else 'UTC'
  204. ),
  205. _("Don't forget to check the weather forecast before you go out with your equipment.")
  206. ])) \
  207. .replace('+++SECTION-EPHEMERIDES+++', _('Ephemerides of the day')) \
  208. .replace('+++EPHEMERIDES-OBJECT+++', _('Object')) \
  209. .replace('+++EPHEMERIDES-RISE-TIME+++', _('Rise time')) \
  210. .replace('+++EPHEMERIDES-CULMINATION-TIME+++', _('Culmination time')) \
  211. .replace('+++EPHEMERIDES-SET-TIME+++', _('Set time')) \
  212. .replace('+++EPHEMERIDES+++', self._make_ephemerides()) \
  213. .replace('+++GRAPH_LABEL_HOURS+++', _('hours')) \
  214. .replace('+++MOON-PHASE-GRAPHICS+++', moon_phase_graphics) \
  215. .replace('+++CURRENT-MOON-PHASE-TITLE+++', _('Moon phase:')) \
  216. .replace('+++CURRENT-MOON-PHASE+++', self.moon_phase.get_phase()) \
  217. .replace('+++SECTION-EVENTS+++', _('Expected events')) \
  218. .replace('+++EVENTS+++', self._make_events())
  219. for aster in ASTERS:
  220. document = document.replace('+++ASTER_%s+++' % aster.skyfield_name.upper().split(' ')[0],
  221. aster.name)
  222. return document
  223. def _make_ephemerides(self) -> str:
  224. latex = []
  225. graph_y_component = 18
  226. if self.ephemerides is not None:
  227. for ephemeris in self.ephemerides:
  228. if ephemeris.rise_time is not None:
  229. time_fmt = TIME_FORMAT if ephemeris.rise_time.day == self.date.day else SHORT_DATETIME_FORMAT
  230. aster_rise = ephemeris.rise_time.strftime(time_fmt)
  231. else:
  232. aster_rise = '-'
  233. if ephemeris.culmination_time is not None:
  234. time_fmt = TIME_FORMAT if ephemeris.culmination_time.day == self.date.day\
  235. else SHORT_DATETIME_FORMAT
  236. aster_culmination = ephemeris.culmination_time.strftime(time_fmt)
  237. else:
  238. aster_culmination = '-'
  239. if ephemeris.set_time is not None:
  240. time_fmt = TIME_FORMAT if ephemeris.set_time.day == self.date.day else SHORT_DATETIME_FORMAT
  241. aster_set = ephemeris.set_time.strftime(time_fmt)
  242. else:
  243. aster_set = '-'
  244. if not self.show_graph:
  245. latex.append(r'\object{%s}{%s}{%s}{%s}' % (ephemeris.object.name,
  246. aster_rise,
  247. aster_culmination,
  248. aster_set))
  249. else:
  250. if ephemeris.rise_time is not None:
  251. raise_hour = ephemeris.rise_time.hour
  252. raise_minute = ephemeris.rise_time.minute
  253. else:
  254. raise_hour = raise_minute = 0
  255. aster_rise = ''
  256. if ephemeris.set_time is not None:
  257. set_hour = ephemeris.set_time.hour
  258. set_minute = ephemeris.set_time.minute
  259. else:
  260. set_hour = 24
  261. set_minute = 0
  262. aster_set = ''
  263. sets_after_end = set_hour > raise_hour
  264. if not sets_after_end:
  265. latex.append(r'\graphobject{%d}{gray}{0}{0}{%d}{%d}{}{%s}' % (graph_y_component,
  266. set_hour,
  267. set_minute,
  268. aster_set))
  269. set_hour = 24
  270. set_minute = 0
  271. latex.append(r'\graphobject{%d}{gray}{%d}{%d}{%d}{%d}{%s}{%s}' % (
  272. graph_y_component,
  273. raise_hour,
  274. raise_minute,
  275. set_hour,
  276. set_minute,
  277. aster_rise,
  278. aster_set if sets_after_end else ''
  279. ))
  280. graph_y_component -= 2
  281. return ''.join(latex)
  282. def _make_events(self) -> str:
  283. latex = []
  284. for event in self.events:
  285. latex.append(r'\event{%s}{%s}' % (event.start_time.strftime(TIME_FORMAT),
  286. event.get_description()))
  287. return ''.join(latex)
  288. @staticmethod
  289. def _remove_section(document: str, section: str):
  290. begin_section_tag = '%%%%%% BEGIN-%s-SECTION' % section.upper()
  291. end_section_tag = '%%%%%% END-%s-SECTION' % section.upper()
  292. document = document.split('\n')
  293. new_document = []
  294. ignore_line = False
  295. for line in document:
  296. if begin_section_tag in line or end_section_tag in line:
  297. ignore_line = not ignore_line
  298. continue
  299. if ignore_line:
  300. continue
  301. new_document.append(line)
  302. return '\n'.join(new_document)
  303. class PdfDumper(Dumper):
  304. def to_string(self):
  305. try:
  306. latex_dumper = _LatexDumper(self.ephemerides, self.moon_phase, self.events,
  307. date=self.date, timezone=self.timezone, with_colors=self.with_colors,
  308. show_graph=self.show_graph)
  309. return self._compile(latex_dumper.to_string())
  310. except RuntimeError:
  311. raise UnavailableFeatureError(_("Building PDFs was not possible, because some dependencies are not"
  312. " installed.\nPlease look at the documentation at http://kosmorro.space "
  313. "for more information."))
  314. @staticmethod
  315. def is_file_output_needed() -> bool:
  316. return True
  317. @staticmethod
  318. def _compile(latex_input) -> bytes:
  319. if build_pdf is None:
  320. raise RuntimeError('Python latex module not found')
  321. package = str(Path(__file__).parent.absolute()) + '/assets/pdf/'
  322. return bytes(build_pdf(latex_input, [package]))