@@ -145,7 +145,8 @@ disable=print-statement, | |||||
too-many-locals, | too-many-locals, | ||||
too-many-branches, | too-many-branches, | ||||
too-few-public-methods, | too-few-public-methods, | ||||
protected-access | |||||
protected-access, | |||||
unnecessary-comprehension | |||||
# Enable the message, report, category or checker with the given id(s). You can | # Enable the message, report, category or checker with the given id(s). You can | ||||
# either give multiple identifier separated by comma (,) or put this option | # either give multiple identifier separated by comma (,) or put this option | ||||
@@ -24,6 +24,7 @@ from kosmorrolib.version import VERSION | |||||
from kosmorrolib import dumper | from kosmorrolib import dumper | ||||
from kosmorrolib import core | from kosmorrolib import core | ||||
from kosmorrolib.ephemerides import EphemeridesComputer, Position | from kosmorrolib.ephemerides import EphemeridesComputer, Position | ||||
from kosmorrolib import events | |||||
def main(): | def main(): | ||||
@@ -37,13 +38,17 @@ def main(): | |||||
month = args.month | month = args.month | ||||
day = args.day | day = args.day | ||||
compute_date = date(year, month, day) | |||||
if day is not None and month is None: | if day is not None and month is None: | ||||
month = date.today().month | month = date.today().month | ||||
ephemeris = EphemeridesComputer(Position(args.latitude, args.longitude)) | ephemeris = EphemeridesComputer(Position(args.latitude, args.longitude)) | ||||
ephemerides = ephemeris.compute_ephemerides(year, month, day) | ephemerides = ephemeris.compute_ephemerides(year, month, day) | ||||
dump = output_formats[args.format](ephemerides, date(year, month, day)) | |||||
events_list = events.search_events(compute_date) | |||||
dump = output_formats[args.format](ephemerides, events_list, compute_date) | |||||
print(dump.to_string()) | print(dump.to_string()) | ||||
return 0 | return 0 | ||||
@@ -33,6 +33,10 @@ MOON_PHASES = { | |||||
'WANING_CRESCENT': 'Waning crescent' | 'WANING_CRESCENT': 'Waning crescent' | ||||
} | } | ||||
EVENTS = { | |||||
'OPPOSITION': {'message': '%s is in opposition'} | |||||
} | |||||
class MoonPhase: | class MoonPhase: | ||||
def __init__(self, identifier: str, time: Union[Time, None], next_phase_date: Union[Time, None]): | def __init__(self, identifier: str, time: Union[Time, None], next_phase_date: Union[Time, None]): | ||||
@@ -131,3 +135,20 @@ class DwarfPlanet(Planet): | |||||
class Satellite(Object): | class Satellite(Object): | ||||
def get_type(self) -> str: | def get_type(self) -> str: | ||||
return 'satellite' | return 'satellite' | ||||
class Event: | |||||
def __init__(self, event_type: str, aster: [Object], start_time: Time, end_time: Union[Time, None] = 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) | |||||
) | |||||
self.event_type = event_type | |||||
self.object = aster | |||||
self.start_time = start_time | |||||
self.end_time = end_time | |||||
def get_description(self) -> str: | |||||
return EVENTS[self.event_type]['message'] % self.object.name |
@@ -22,12 +22,13 @@ import json | |||||
from tabulate import tabulate | from tabulate import tabulate | ||||
from skyfield.timelib import Time | from skyfield.timelib import Time | ||||
from numpy import int64 | from numpy import int64 | ||||
from .data import Object, AsterEphemerides, MoonPhase | |||||
from .data import Object, AsterEphemerides, MoonPhase, Event | |||||
class Dumper(ABC): | class Dumper(ABC): | ||||
def __init__(self, ephemeris: dict, date: datetime.date = datetime.date.today()): | |||||
def __init__(self, ephemeris: dict, events: [Event], date: datetime.date = datetime.date.today()): | |||||
self.ephemeris = ephemeris | self.ephemeris = ephemeris | ||||
self.events = events | |||||
self.date = date | self.date = date | ||||
@abstractmethod | @abstractmethod | ||||
@@ -37,6 +38,7 @@ class Dumper(ABC): | |||||
class JsonDumper(Dumper): | class JsonDumper(Dumper): | ||||
def to_string(self): | def to_string(self): | ||||
self.ephemeris['events'] = self.events | |||||
return json.dumps(self.ephemeris, | return json.dumps(self.ephemeris, | ||||
default=self._json_default, | default=self._json_default, | ||||
indent=4) | indent=4) | ||||
@@ -60,16 +62,31 @@ class JsonDumper(Dumper): | |||||
moon_phase['phase'] = moon_phase.pop('identifier') | moon_phase['phase'] = moon_phase.pop('identifier') | ||||
moon_phase['date'] = moon_phase.pop('time') | moon_phase['date'] = moon_phase.pop('time') | ||||
return moon_phase | return moon_phase | ||||
if isinstance(obj, Event): | |||||
event = obj.__dict__ | |||||
event['object'] = event['object'].name | |||||
return event | |||||
raise TypeError('Object of type "%s" could not be integrated in the JSON' % str(type(obj))) | raise TypeError('Object of type "%s" could not be integrated in the JSON' % str(type(obj))) | ||||
class TextDumper(Dumper): | class TextDumper(Dumper): | ||||
def to_string(self): | def to_string(self): | ||||
return '\n\n'.join(['Ephemerides of %s' % self.date.strftime('%A %B %d, %Y'), | |||||
text = 'Ephemerides of %s' % self.date.strftime('%A %B %d, %Y') | |||||
text = '\n\n'.join([text, | |||||
self.get_asters(self.ephemeris['details']), | self.get_asters(self.ephemeris['details']), | ||||
self.get_moon(self.ephemeris['moon_phase']), | |||||
'Note: All the hours are given in UTC.']) | |||||
self.get_moon(self.ephemeris['moon_phase']) | |||||
]) | |||||
if len(self.events) > 0: | |||||
text = '\n\n'.join([text, | |||||
'Expected events:', | |||||
self.get_events(self.events) | |||||
]) | |||||
text = '\n\n'.join([text, 'Note: All the hours are given in UTC.']) | |||||
return text | |||||
@staticmethod | @staticmethod | ||||
def get_asters(asters: [Object]) -> str: | def get_asters(asters: [Object]) -> str: | ||||
@@ -98,6 +115,15 @@ class TextDumper(Dumper): | |||||
return tabulate(data, headers=['Object', 'Rise time', 'Culmination time', 'Set time'], tablefmt='simple', | return tabulate(data, headers=['Object', 'Rise time', 'Culmination time', 'Set time'], tablefmt='simple', | ||||
stralign='center', colalign=('left',)) | stralign='center', colalign=('left',)) | ||||
@staticmethod | |||||
def get_events(events: [Event]) -> str: | |||||
data = [] | |||||
for event in events: | |||||
data.append([event.start_time.utc_strftime('%H:%M'), event.get_description()]) | |||||
return tabulate(data, tablefmt='plain', stralign='left') | |||||
@staticmethod | @staticmethod | ||||
def get_moon(moon_phase: MoonPhase) -> str: | def get_moon(moon_phase: MoonPhase) -> str: | ||||
return 'Moon phase: %s\n' \ | return 'Moon phase: %s\n' \ | ||||
@@ -0,0 +1,61 @@ | |||||
#!/usr/bin/env python3 | |||||
# Kosmorro - Compute The Next Ephemerides | |||||
# Copyright (C) 2019 Jérôme Deuchnord <jerome@deuchnord.fr> | |||||
# | |||||
# This program is free software: you can redistribute it and/or modify | |||||
# it under the terms of the GNU Affero General Public License as | |||||
# published by the Free Software Foundation, either version 3 of the | |||||
# License, or (at your option) any later version. | |||||
# | |||||
# This program is distributed in the hope that it will be useful, | |||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||||
# GNU Affero General Public License for more details. | |||||
# | |||||
# You should have received a copy of the GNU Affero General Public License | |||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. | |||||
from datetime import date as date_type | |||||
from skyfield.timelib import Time | |||||
from skyfield.almanac import find_discrete | |||||
from .data import Event, Planet | |||||
from .core import get_timescale, get_skf_objects, ASTERS | |||||
def _search_oppositions(start_time: Time, end_time: Time) -> [Event]: | |||||
earth = get_skf_objects()['earth'] | |||||
sun = get_skf_objects()['sun'] | |||||
aster = None | |||||
def is_oppositing(time: Time) -> [bool]: | |||||
earth_pos = earth.at(time) | |||||
sun_pos = earth_pos.observe(sun).apparent() # Never do this without eyes protection! | |||||
aster_pos = earth_pos.observe(get_skf_objects()[aster.skyfield_name]).apparent() | |||||
_, lon1, _ = sun_pos.ecliptic_latlon() | |||||
_, lon2, _ = aster_pos.ecliptic_latlon() | |||||
return (lon1.degrees - lon2.degrees) > 180 | |||||
is_oppositing.rough_period = 1.0 | |||||
events = [] | |||||
for aster in ASTERS: | |||||
if not isinstance(aster, Planet) or aster.name in ['Mercury', 'Venus']: | |||||
continue | |||||
times, _ = find_discrete(start_time, end_time, is_oppositing) | |||||
for time in times: | |||||
events.append(Event('OPPOSITION', aster, time)) | |||||
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 [ | |||||
opposition for opposition in _search_oppositions(start_time, end_time) | |||||
] |
@@ -1,2 +1,3 @@ | |||||
from .dumper import * | from .dumper import * | ||||
from .ephemerides import * | from .ephemerides import * | ||||
from .events import * |
@@ -1,17 +1,22 @@ | |||||
import unittest | import unittest | ||||
from kosmorrolib.data import AsterEphemerides, Planet, MoonPhase | |||||
from kosmorrolib.dumper import JsonDumper | |||||
from datetime import date | |||||
from kosmorrolib.data import AsterEphemerides, Planet, MoonPhase, Event | |||||
from kosmorrolib.dumper import JsonDumper, TextDumper | |||||
from kosmorrolib.core import get_timescale | from kosmorrolib.core import get_timescale | ||||
class DumperTestCase(unittest.TestCase): | class DumperTestCase(unittest.TestCase): | ||||
def setUp(self) -> None: | |||||
self.maxDiff = None | |||||
def test_json_dumper_returns_correct_json(self): | def test_json_dumper_returns_correct_json(self): | ||||
data = self._get_data() | data = self._get_data() | ||||
self.assertEqual('{\n' | self.assertEqual('{\n' | ||||
' "moon_phase": {\n' | ' "moon_phase": {\n' | ||||
' "next_phase_date": "2019-11-20T00:00:00Z",\n' | |||||
' "next_phase_date": "2019-10-21T00:00:00Z",\n' | |||||
' "phase": "FULL_MOON",\n' | ' "phase": "FULL_MOON",\n' | ||||
' "date": "2019-11-11T00:00:00Z"\n' | |||||
' "date": "2019-10-14T00:00:00Z"\n' | |||||
' },\n' | ' },\n' | ||||
' "details": [\n' | ' "details": [\n' | ||||
' {\n' | ' {\n' | ||||
@@ -22,13 +27,51 @@ class DumperTestCase(unittest.TestCase): | |||||
' "set_time": null\n' | ' "set_time": null\n' | ||||
' }\n' | ' }\n' | ||||
' }\n' | ' }\n' | ||||
' ],\n' | |||||
' "events": [\n' | |||||
' {\n' | |||||
' "event_type": "OPPOSITION",\n' | |||||
' "object": "Mars",\n' | |||||
' "start_time": "2018-07-27T05:12:00Z",\n' | |||||
' "end_time": null\n' | |||||
' }\n' | |||||
' ]\n' | ' ]\n' | ||||
'}', JsonDumper(data).to_string()) | |||||
'}', JsonDumper(data, | |||||
[Event('OPPOSITION', Planet('Mars', 'MARS'), | |||||
get_timescale().utc(2018, 7, 27, 5, 12))] | |||||
).to_string()) | |||||
def test_text_dumper_without_events(self): | |||||
ephemerides = self._get_data() | |||||
self.assertEqual('Ephemerides of 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 Mon Oct 21, 2019 00:00\n\n' | |||||
'Note: All the hours are given in UTC.', | |||||
TextDumper(ephemerides, [], date=date(2019, 10, 14)).to_string()) | |||||
def test_text_dumper_with_events(self): | |||||
ephemerides = self._get_data() | |||||
self.assertEqual('Ephemerides of 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 Mon Oct 21, 2019 00:00\n\n' | |||||
'Expected events:\n\n' | |||||
'05:12 Mars is in opposition\n\n' | |||||
'Note: All the hours are given in UTC.', | |||||
TextDumper(ephemerides, [Event('OPPOSITION', | |||||
Planet('Mars', 'MARS'), | |||||
get_timescale().utc(2018, 7, 27, 5, 12)) | |||||
], date=date(2019, 10, 14)).to_string()) | |||||
@staticmethod | @staticmethod | ||||
def _get_data(): | def _get_data(): | ||||
return { | return { | ||||
'moon_phase': MoonPhase('FULL_MOON', get_timescale().utc(2019, 11, 11), get_timescale().utc(2019, 11, 20)), | |||||
'moon_phase': MoonPhase('FULL_MOON', get_timescale().utc(2019, 10, 14), get_timescale().utc(2019, 10, 21)), | |||||
'details': [Planet('Mars', 'MARS', AsterEphemerides(None, None, None))] | 'details': [Planet('Mars', 'MARS', AsterEphemerides(None, None, None))] | ||||
} | } | ||||
@@ -0,0 +1,33 @@ | |||||
import unittest | |||||
from datetime import date | |||||
from kosmorrolib import events | |||||
from kosmorrolib.data import Event | |||||
from kosmorrolib.core import get_timescale | |||||
class MyTestCase(unittest.TestCase): | |||||
def test_event_only_accepts_valid_values(self): | |||||
with self.assertRaises(ValueError): | |||||
Event('SUPERNOVA', None, get_timescale().now()) | |||||
def test_find_oppositions(self): | |||||
# Test case: Mars opposition | |||||
# Source of the information: https://promenade.imcce.fr/en/pages6/887.html#mar | |||||
o1 = (events.search_events(date(2020, 10, 13)), '^2020-10-13T23:25') | |||||
o2 = (events.search_events(date(2022, 12, 8)), '^2022-12-08T05:41') | |||||
o3 = (events.search_events(date(2025, 1, 16)), '^2025-01-16T02:38') | |||||
o4 = (events.search_events(date(2027, 2, 19)), '^2027-02-19T15:50') | |||||
for (o, expected_date) in [o1, o2, o3, o4]: | |||||
self.assertEqual(1, len(o), 'Expected 1 event for %s, got %d' % (expected_date, len(o))) | |||||
self.assertEqual('OPPOSITION', o[0].event_type) | |||||
self.assertEqual('MARS', o[0].object.skyfield_name) | |||||
self.assertRegex(o[0].start_time.utc_iso(), expected_date) | |||||
self.assertIsNone(o[0].end_time) | |||||
self.assertEqual('Mars is in opposition', o[0].get_description()) | |||||
if __name__ == '__main__': | |||||
unittest.main() |