From 9dbc09363134f6b4647d07cb54aea387e1a36fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Deuchnord?= Date: Wed, 19 Feb 2020 08:19:24 +0100 Subject: [PATCH] feat: add support for maximal elongations of Mercury and Venus --- kosmorrolib/assets/pdf/template.tex | 3 +- kosmorrolib/core.py | 54 ----------------- kosmorrolib/data.py | 78 ++++++++++++++++++++++--- kosmorrolib/ephemerides.py | 18 +++--- kosmorrolib/events.py | 38 ++++++++++-- kosmorrolib/locales/messages.pot | 91 +++++++++++++++-------------- test/dumper.py | 59 ++++++++++++++----- test/events.py | 27 +++++++++ 8 files changed, 235 insertions(+), 133 deletions(-) diff --git a/kosmorrolib/assets/pdf/template.tex b/kosmorrolib/assets/pdf/template.tex index 6cb87ed..d5d148b 100644 --- a/kosmorrolib/assets/pdf/template.tex +++ b/kosmorrolib/assets/pdf/template.tex @@ -6,8 +6,9 @@ \usepackage{graphicx} \usepackage{hyperref} -% Fix non-break spaces issues +% Fix Unicode issues \DeclareUnicodeCharacter{202F}{~} +\DeclareUnicodeCharacter{00B0}{$^\circ$} \hypersetup{pdfinfo={ Title={+++DOCUMENT-TITLE+++}, diff --git a/kosmorrolib/core.py b/kosmorrolib/core.py index 7768fb1..81cec74 100644 --- a/kosmorrolib/core.py +++ b/kosmorrolib/core.py @@ -18,30 +18,13 @@ from shutil import rmtree from pathlib import Path -from typing import Union from skyfield.api import Loader from skyfield.timelib import Time from skyfield.nutationlib import iau2000b -from .data import Star, Planet, Satellite, MOON_PHASES, MoonPhase -from .i18n import _ - CACHE_FOLDER = str(Path.home()) + '/.kosmorro-cache' -MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] - -ASTERS = [Star(_('Sun'), 'SUN'), - Satellite(_('Moon'), 'MOON'), - Planet(_('Mercury'), 'MERCURY'), - Planet(_('Venus'), 'VENUS'), - Planet(_('Mars'), 'MARS'), - Planet(_('Jupiter'), 'JUPITER BARYCENTER'), - Planet(_('Saturn'), 'SATURN BARYCENTER'), - Planet(_('Uranus'), 'URANUS BARYCENTER'), - Planet(_('Neptune'), 'NEPTUNE BARYCENTER'), - Planet(_('Pluto'), 'PLUTO BARYCENTER')] - def get_loader(): return Loader(CACHE_FOLDER) @@ -63,43 +46,6 @@ def clear_cache(): rmtree(CACHE_FOLDER) -def skyfield_to_moon_phase(times: [Time], vals: [int], now: Time) -> Union[MoonPhase, None]: - tomorrow = get_timescale().utc(now.utc_datetime().year, now.utc_datetime().month, now.utc_datetime().day + 1) - - phases = list(MOON_PHASES.keys()) - current_phase = None - current_phase_time = None - next_phase_time = None - i = 0 - - if len(times) == 0: - return None - - for i, time in enumerate(times): - if now.utc_iso() <= time.utc_iso(): - if vals[i] in [0, 2, 4, 6]: - if time.utc_datetime() < tomorrow.utc_datetime(): - current_phase_time = time - current_phase = phases[vals[i]] - else: - i -= 1 - current_phase_time = None - current_phase = phases[vals[i]] - else: - current_phase = phases[vals[i]] - - break - - for j in range(i + 1, len(times)): - if vals[j] in [0, 2, 4, 6]: - next_phase_time = times[j] - break - - return MoonPhase(current_phase, - current_phase_time.utc_datetime() if current_phase_time is not None else None, - next_phase_time.utc_datetime() if next_phase_time is not None else None) - - def flatten_list(the_list: list): new_list = [] for item in the_list: diff --git a/kosmorrolib/data.py b/kosmorrolib/data.py index 89d9fa3..ac50df7 100644 --- a/kosmorrolib/data.py +++ b/kosmorrolib/data.py @@ -20,8 +20,10 @@ from abc import ABC, abstractmethod from typing import Union from datetime import datetime -from skyfield.api import Topos +from skyfield.api import Topos, Time +from skyfield.vectorlib import VectorSum as SkfPlanet +from .core import get_skf_objects, get_timescale from .i18n import _ MOON_PHASES = { @@ -37,7 +39,8 @@ MOON_PHASES = { EVENTS = { 'OPPOSITION': {'message': _('%s is in opposition')}, - 'CONJUNCTION': {'message': _('%s and %s are in conjunction')} + 'CONJUNCTION': {'message': _('%s and %s are in conjunction')}, + 'MAXIMAL_ELONGATION': {'message': _("%s's largest elongation")} } @@ -115,6 +118,9 @@ class Object(ABC): self.skyfield_name = skyfield_name self.ephemerides = ephemerides + def get_skyfield_object(self) -> SkfPlanet: + return get_skf_objects()[self.skyfield_name] + @abstractmethod def get_type(self) -> str: pass @@ -142,23 +148,77 @@ class Satellite(Object): class Event: def __init__(self, event_type: str, objects: [Object], start_time: datetime, - end_time: Union[datetime, None] = None): + end_time: Union[datetime, None] = None, details: str = None): if event_type not in EVENTS.keys(): - raise ValueError('event_type parameter must be one of the following: %s (got %s)' % ( - ', '.join(EVENTS.keys()), - event_type) - ) + accepted_types = ', '.join(EVENTS.keys()) + raise ValueError('event_type parameter must be one of the following: %s (got %s)' % (accepted_types, + event_type)) self.event_type = event_type self.objects = objects self.start_time = start_time self.end_time = end_time + self.details = details - def get_description(self) -> str: - return EVENTS[self.event_type]['message'] % self._get_objects_name() + def get_description(self, show_details: bool = True) -> str: + description = EVENTS[self.event_type]['message'] % self._get_objects_name() + if show_details and self.details is not None: + description += ' ({:s})'.format(self.details) + return description def _get_objects_name(self): if len(self.objects) == 1: return self.objects[0].name return tuple(object.name for object in self.objects) + + +def skyfield_to_moon_phase(times: [Time], vals: [int], now: Time) -> Union[MoonPhase, None]: + tomorrow = get_timescale().utc(now.utc_datetime().year, now.utc_datetime().month, now.utc_datetime().day + 1) + + phases = list(MOON_PHASES.keys()) + current_phase = None + current_phase_time = None + next_phase_time = None + i = 0 + + if len(times) == 0: + return None + + for i, time in enumerate(times): + if now.utc_iso() <= time.utc_iso(): + if vals[i] in [0, 2, 4, 6]: + if time.utc_datetime() < tomorrow.utc_datetime(): + current_phase_time = time + current_phase = phases[vals[i]] + else: + i -= 1 + current_phase_time = None + current_phase = phases[vals[i]] + else: + current_phase = phases[vals[i]] + + break + + for j in range(i + 1, len(times)): + if vals[j] in [0, 2, 4, 6]: + next_phase_time = times[j] + break + + return MoonPhase(current_phase, + current_phase_time.utc_datetime() if current_phase_time is not None else None, + next_phase_time.utc_datetime() if next_phase_time is not None else None) + + +MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] + +ASTERS = [Star(_('Sun'), 'SUN'), + Satellite(_('Moon'), 'MOON'), + Planet(_('Mercury'), 'MERCURY'), + Planet(_('Venus'), 'VENUS'), + Planet(_('Mars'), 'MARS'), + Planet(_('Jupiter'), 'JUPITER BARYCENTER'), + Planet(_('Saturn'), 'SATURN BARYCENTER'), + Planet(_('Uranus'), 'URANUS BARYCENTER'), + Planet(_('Neptune'), 'NEPTUNE BARYCENTER'), + Planet(_('Pluto'), 'PLUTO BARYCENTER')] diff --git a/kosmorrolib/ephemerides.py b/kosmorrolib/ephemerides.py index c1e76c4..8052944 100644 --- a/kosmorrolib/ephemerides.py +++ b/kosmorrolib/ephemerides.py @@ -24,8 +24,8 @@ from skyfield.searchlib import find_discrete, find_maxima from skyfield.timelib import Time from skyfield.constants import tau -from .data import Object, Position, AsterEphemerides, MoonPhase -from .core import get_skf_objects, get_timescale, get_iau2000b, ASTERS, MONTHS, skyfield_to_moon_phase +from .data import Object, Position, AsterEphemerides, MoonPhase, ASTERS, MONTHS, skyfield_to_moon_phase +from .core import get_skf_objects, get_timescale, get_iau2000b RISEN_ANGLE = -0.8333 @@ -88,6 +88,7 @@ class EphemeridesComputer: rise_times, arr = find_discrete(start_time, end_time, is_risen) try: culmination_time, _ = find_maxima(start_time, end_time, f=get_angle, epsilon=1./3600/24, num=12) + culmination_time = culmination_time[0] if len(culmination_time) > 0 else None except ValueError: culmination_time = None @@ -98,12 +99,15 @@ class EphemeridesComputer: rise_time = rise_times[0] if arr[0] else None set_time = rise_times[0] if not arr[0] else None - culmination_time = culmination_time[0] if culmination_time is not None else None - # Convert the Time instances to Python datetime objects - rise_time = rise_time.utc_datetime().replace(microsecond=0) - culmination_time = culmination_time.utc_datetime().replace(microsecond=0) - set_time = set_time.utc_datetime().replace(microsecond=0) + if rise_time is not None: + rise_time = rise_time.utc_datetime().replace(microsecond=0) + + if culmination_time is not None: + culmination_time = culmination_time.utc_datetime().replace(microsecond=0) + + if set_time is not None: + set_time = set_time.utc_datetime().replace(microsecond=0) if set_time is not None else None aster.ephemerides = AsterEphemerides(rise_time, culmination_time, set_time) return aster diff --git a/kosmorrolib/events.py b/kosmorrolib/events.py index fc5e258..352e3b0 100644 --- a/kosmorrolib/events.py +++ b/kosmorrolib/events.py @@ -19,10 +19,10 @@ from datetime import date as date_type from skyfield.timelib import Time -from skyfield.almanac import find_discrete +from skyfield.searchlib import find_discrete, find_maxima -from .data import Event, Planet -from .core import get_timescale, get_skf_objects, ASTERS, flatten_list +from .data import Event, Planet, ASTERS +from .core import get_timescale, get_skf_objects, flatten_list def _search_conjunction(start_time: Time, end_time: Time) -> [Event]: @@ -91,11 +91,41 @@ def _search_oppositions(start_time: Time, end_time: Time) -> [Event]: return events +def _search_maximal_elongations(start_time: Time, end_time: Time) -> [Event]: + earth = get_skf_objects()['earth'] + sun = get_skf_objects()['sun'] + aster = None + + def get_elongation(time: Time): + sun_pos = (sun - earth).at(time) + aster_pos = (aster.get_skyfield_object() - earth).at(time) + separation = sun_pos.separation_from(aster_pos) + return separation.degrees + + get_elongation.rough_period = 1.0 + + events = [] + + for aster in ASTERS: + if aster.skyfield_name not in ['MERCURY', 'VENUS']: + continue + + times, elongations = find_maxima(start_time, end_time, f=get_elongation, epsilon=1./24/3600, num=12) + + for i, time in enumerate(times): + elongation = elongations[i] + events.append(Event('MAXIMAL_ELONGATION', [aster], time.utc_datetime(), + details='{:.3n}°'.format(elongation))) + + return events + + def search_events(date: date_type) -> [Event]: start_time = get_timescale().utc(date.year, date.month, date.day) end_time = get_timescale().utc(date.year, date.month, date.day + 1) return sorted(flatten_list([ _search_oppositions(start_time, end_time), - _search_conjunction(start_time, end_time) + _search_conjunction(start_time, end_time), + _search_maximal_elongations(start_time, end_time) ]), key=lambda event: event.start_time) diff --git a/kosmorrolib/locales/messages.pot b/kosmorrolib/locales/messages.pot index d5a1268..1cf6449 100644 --- a/kosmorrolib/locales/messages.pot +++ b/kosmorrolib/locales/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: kosmorro 0.5.2\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2020-02-17 20:58+0100\n" +"POT-Creation-Date: 2020-02-21 20:14+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,86 +17,91 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: kosmorrolib/core.py:34 -msgid "Sun" +#: kosmorrolib/data.py:30 +msgid "New Moon" msgstr "" -#: kosmorrolib/core.py:35 -msgid "Moon" +#: kosmorrolib/data.py:31 +msgid "Waxing crescent" msgstr "" -#: kosmorrolib/core.py:36 -msgid "Mercury" +#: kosmorrolib/data.py:32 +msgid "First Quarter" msgstr "" -#: kosmorrolib/core.py:37 -msgid "Venus" +#: kosmorrolib/data.py:33 +msgid "Waxing gibbous" msgstr "" -#: kosmorrolib/core.py:38 -msgid "Mars" +#: kosmorrolib/data.py:34 +msgid "Full Moon" msgstr "" -#: kosmorrolib/core.py:39 -msgid "Jupiter" +#: kosmorrolib/data.py:35 +msgid "Waning gibbous" msgstr "" -#: kosmorrolib/core.py:40 -msgid "Saturn" +#: kosmorrolib/data.py:36 +msgid "Last Quarter" msgstr "" -#: kosmorrolib/core.py:41 -msgid "Uranus" +#: kosmorrolib/data.py:37 +msgid "Waning crescent" msgstr "" -#: kosmorrolib/core.py:42 -msgid "Neptune" +#: kosmorrolib/data.py:41 +#, python-format +msgid "%s is in opposition" msgstr "" -#: kosmorrolib/core.py:43 -msgid "Pluto" +#: kosmorrolib/data.py:42 +#, python-format +msgid "%s and %s are in conjunction" msgstr "" -#: kosmorrolib/data.py:28 -msgid "New Moon" +#: kosmorrolib/data.py:43 +#, python-format +msgid "%s's largest elongation" msgstr "" -#: kosmorrolib/data.py:29 -msgid "Waxing crescent" +#: kosmorrolib/data.py:215 +msgid "Sun" msgstr "" -#: kosmorrolib/data.py:30 -msgid "First Quarter" +#: kosmorrolib/data.py:216 +msgid "Moon" msgstr "" -#: kosmorrolib/data.py:31 -msgid "Waxing gibbous" +#: kosmorrolib/data.py:217 +msgid "Mercury" msgstr "" -#: kosmorrolib/data.py:32 -msgid "Full Moon" +#: kosmorrolib/data.py:218 +msgid "Venus" msgstr "" -#: kosmorrolib/data.py:33 -msgid "Waning gibbous" +#: kosmorrolib/data.py:219 +msgid "Mars" msgstr "" -#: kosmorrolib/data.py:34 -msgid "Last Quarter" +#: kosmorrolib/data.py:220 +msgid "Jupiter" msgstr "" -#: kosmorrolib/data.py:35 -msgid "Waning crescent" +#: kosmorrolib/data.py:221 +msgid "Saturn" msgstr "" -#: kosmorrolib/data.py:39 -#, python-format -msgid "%s is in opposition" +#: kosmorrolib/data.py:222 +msgid "Uranus" msgstr "" -#: kosmorrolib/data.py:40 -#, python-format -msgid "%s and %s are in conjunction" +#: kosmorrolib/data.py:223 +msgid "Neptune" +msgstr "" + +#: kosmorrolib/data.py:224 +msgid "Pluto" msgstr "" #: kosmorrolib/dumper.py:35 diff --git a/test/dumper.py b/test/dumper.py index 92ecb54..de02454 100644 --- a/test/dumper.py +++ b/test/dumper.py @@ -23,7 +23,17 @@ class DumperTestCase(unittest.TestCase): ' "Mars"\n' ' ],\n' ' "start_time": "2019-10-14T23:00:00",\n' - ' "end_time": null\n' + ' "end_time": null,\n' + ' "details": null\n' + ' },\n' + ' {\n' + ' "event_type": "MAXIMAL_ELONGATION",\n' + ' "objects": [\n' + ' "Venus"\n' + ' ],\n' + ' "start_time": "2019-10-14T12:00:00",\n' + ' "end_time": null,\n' + ' "details": "42.0\\u00b0"\n' ' }\n' ' ],\n' ' "ephemerides": [\n' @@ -52,7 +62,17 @@ class DumperTestCase(unittest.TestCase): ' "Mars"\n' ' ],\n' ' "start_time": "2019-10-14T23:00:00",\n' - ' "end_time": null\n' + ' "end_time": null,\n' + ' "details": null\n' + ' },\n' + ' {\n' + ' "event_type": "MAXIMAL_ELONGATION",\n' + ' "objects": [\n' + ' "Venus"\n' + ' ],\n' + ' "start_time": "2019-10-14T12:00:00",\n' + ' "end_time": null,\n' + ' "details": "42.0\\u00b0"\n' ' }\n' ' ],\n' ' "ephemerides": [\n' @@ -92,15 +112,16 @@ class DumperTestCase(unittest.TestCase): def test_text_dumper_with_events(self): ephemerides = self._get_data() - self.assertEqual('Monday October 14, 2019\n\n' - 'Object Rise time Culmination time Set time\n' - '-------- ----------- ------------------ ----------\n' - 'Mars - - -\n\n' - 'Moon phase: Full Moon\n' - 'Last Quarter on Monday October 21, 2019 at 00:00\n\n' - 'Expected events:\n' - '23:00 Mars is in opposition\n\n' - 'Note: All the hours are given in UTC.', + self.assertEqual("Monday October 14, 2019\n\n" + "Object Rise time Culmination time Set time\n" + "-------- ----------- ------------------ ----------\n" + "Mars - - -\n\n" + "Moon phase: Full Moon\n" + "Last Quarter on Monday October 21, 2019 at 00:00\n\n" + "Expected events:\n" + "23:00 Mars is in opposition\n" + "12:00 Venus's largest elongation (42.0°)\n\n" + "Note: All the hours are given in UTC.", TextDumper(ephemerides, self._get_events(), date=date(2019, 10, 14), with_colors=False).to_string()) def test_text_dumper_without_ephemerides_and_with_events(self): @@ -109,7 +130,8 @@ class DumperTestCase(unittest.TestCase): 'Moon phase: Full Moon\n' 'Last Quarter on Monday October 21, 2019 at 00:00\n\n' 'Expected events:\n' - '23:00 Mars is in opposition\n\n' + '23:00 Mars is in opposition\n' + "12:00 Venus's largest elongation (42.0°)\n\n" 'Note: All the hours are given in UTC.', TextDumper(ephemerides, self._get_events(), date=date(2019, 10, 14), with_colors=False).to_string()) @@ -122,7 +144,8 @@ class DumperTestCase(unittest.TestCase): 'Moon phase: Full Moon\n' 'Last Quarter on Monday October 21, 2019 at 01:00\n\n' 'Expected events:\n' - 'Oct 15, 00:00 Mars is in opposition\n\n' + 'Oct 15, 00:00 Mars is in opposition\n' + "13:00 Venus's largest elongation (42.0°)\n\n" 'Note: All the hours are given in the UTC+1 timezone.', TextDumper(ephemerides, self._get_events(), date=date(2019, 10, 14), with_colors=False, timezone=1).to_string()) @@ -134,7 +157,8 @@ class DumperTestCase(unittest.TestCase): 'Moon phase: Full Moon\n' 'Last Quarter on Sunday October 20, 2019 at 23:00\n\n' 'Expected events:\n' - '22:00 Mars is in opposition\n\n' + '22:00 Mars is in opposition\n' + "11:00 Venus's largest elongation (42.0°)\n\n" 'Note: All the hours are given in the UTC-1 timezone.', TextDumper(ephemerides, self._get_events(), date=date(2019, 10, 14), with_colors=False, timezone=-1).to_string()) @@ -146,6 +170,7 @@ class DumperTestCase(unittest.TestCase): self.assertRegex(latex, r'\\section{\\sffamily Ephemerides of the day}') self.assertRegex(latex, r'\\object\{Mars\}\{-\}\{-\}\{-\}') self.assertRegex(latex, r'\\event\{23:00\}\{Mars is in opposition\}') + self.assertRegex(latex, r"\\event\{12:00\}\{Venus's largest elongation \(42.0°\)\}") latex = _LatexDumper(self._get_data(aster_rise_set=True), self._get_events(), date=date(2019, 10, 14)).to_string() @@ -157,6 +182,7 @@ class DumperTestCase(unittest.TestCase): self.assertRegex(latex, 'Full Moon') self.assertRegex(latex, r'\\section{\\sffamily Expected events}') self.assertRegex(latex, r'\\event\{23:00\}\{Mars is in opposition\}') + self.assertRegex(latex, r"\\event\{12:00\}\{Venus's largest elongation \(42.0°\)\}") self.assertNotRegex(latex, r'\\object\{Mars\}\{-\}\{-\}\{-\}') self.assertNotRegex(latex, r'\\section{\\sffamily Ephemerides of the day}') @@ -186,7 +212,10 @@ class DumperTestCase(unittest.TestCase): def _get_events(): return [Event('OPPOSITION', [Planet('Mars', 'MARS')], - datetime(2019, 10, 14, 23, 00)) + datetime(2019, 10, 14, 23, 00)), + Event('MAXIMAL_ELONGATION', + [Planet('Venus', 'VENUS')], + datetime(2019, 10, 14, 12, 00), details='42.0°'), ] diff --git a/test/events.py b/test/events.py index 60ff43b..0e1a1dd 100644 --- a/test/events.py +++ b/test/events.py @@ -55,6 +55,33 @@ class MyTestCase(unittest.TestCase): i += 1 + def test_find_maximal_elongation(self): + e = events.search_events(date(2020, 2, 10)) + self.assertEquals(1, len(e), 'Expected 1 events, got %d.' % len(e)) + e = e[0] + self.assertEquals('MAXIMAL_ELONGATION', e.event_type) + self.assertEquals(1, len(e.objects)) + self.assertEquals('MERCURY', e.objects[0].skyfield_name) + self.assertEqual('18.2°', e.details) + self.assertEquals((2020, 2, 10, 13, 46), (e.start_time.year, e.start_time.month, e.start_time.day, + e.start_time.hour, e.start_time.minute)) + + e = events.search_events(date(2020, 3, 24)) + self.assertEquals(2, len(e), 'Expected 2 events, got %d.' % len(e)) + self.assertEquals('MAXIMAL_ELONGATION', e[0].event_type) + self.assertEquals(1, len(e[0].objects)) + self.assertEquals('MERCURY', e[0].objects[0].skyfield_name) + self.assertEqual('27.8°', e[0].details) + self.assertEquals((2020, 3, 24, 1, 56), (e[0].start_time.year, e[0].start_time.month, e[0].start_time.day, + e[0].start_time.hour, e[0].start_time.minute)) + + self.assertEquals('MAXIMAL_ELONGATION', e[1].event_type) + self.assertEquals(1, len(e[1].objects)) + self.assertEquals('VENUS', e[1].objects[0].skyfield_name) + self.assertEqual('46.1°', e[1].details) + self.assertEquals((2020, 3, 24, 21, 58), (e[1].start_time.year, e[1].start_time.month, e[1].start_time.day, + e[1].start_time.hour, e[1].start_time.minute)) + if __name__ == '__main__': unittest.main()