diff --git a/kosmorrolib/core.py b/kosmorrolib/core.py index c093ddc..230c60d 100644 --- a/kosmorrolib/core.py +++ b/kosmorrolib/core.py @@ -18,9 +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 +from .data import Star, Planet, Satellite, MOON_PHASES, MoonPhase CACHE_FOLDER = str(Path.home()) + '/.kosmorro-cache' @@ -50,5 +54,44 @@ def get_skf_objects(): return get_loader()('de421.bsp') +def get_iau2000b(time: Time): + return iau2000b(time.tt) + + 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, next_phase_time) diff --git a/kosmorrolib/data.py b/kosmorrolib/data.py index 159ff90..58b5eb7 100644 --- a/kosmorrolib/data.py +++ b/kosmorrolib/data.py @@ -24,15 +24,40 @@ from skyfield.timelib import Time MOON_PHASES = { 'NEW_MOON': 'New Moon', + 'WAXING_CRESCENT': 'Waxing crescent', 'FIRST_QUARTER': 'First Quarter', + 'WAXING_GIBBOUS': 'Waxing gibbous', 'FULL_MOON': 'Full Moon', - 'LAST_QUARTER': 'Last Quarter' + 'WANING_GIBBOUS': 'Waning gibbous', + 'LAST_QUARTER': 'Last Quarter', + 'WANING_CRESCENT': 'Waning crescent' } -def skyfield_to_moon_phase(val: int) -> str: - phases = list(MOON_PHASES.keys()) - return phases[val] +class MoonPhase: + def __init__(self, identifier: str, time: Union[Time, None], next_phase_date: Union[Time, None]): + if identifier not in MOON_PHASES.keys(): + raise ValueError('identifier parameter must be one of %s (got %s)' % (', '.join(MOON_PHASES.keys()), + identifier)) + + self.identifier = identifier + self.time = time + self.next_phase_date = next_phase_date + + def get_phase(self): + return MOON_PHASES[self.identifier] + + def get_next_phase(self): + if self.identifier == 'NEW_MOON': + next_identifier = 'FIRST_QUARTER' + elif self.identifier == 'FIRST_QUARTER': + next_identifier = 'FULL_MOON' + elif self.identifier == 'FULL_MOON': + next_identifier = 'LAST_QUARTER' + else: + next_identifier = 'NEW_MOON' + + return MOON_PHASES[next_identifier] class Position: diff --git a/kosmorrolib/dumper.py b/kosmorrolib/dumper.py index 1e49c47..ec9271a 100644 --- a/kosmorrolib/dumper.py +++ b/kosmorrolib/dumper.py @@ -22,7 +22,7 @@ import json from tabulate import tabulate from skyfield.timelib import Time from numpy import int64 -from .data import Object, AsterEphemerides, MOON_PHASES +from .data import Object, AsterEphemerides, MoonPhase class Dumper(ABC): @@ -55,6 +55,11 @@ class JsonDumper(Dumper): return obj if isinstance(obj, AsterEphemerides): return obj.__dict__ + if isinstance(obj, MoonPhase): + moon_phase = obj.__dict__ + moon_phase['phase'] = moon_phase.pop('identifier') + moon_phase['date'] = moon_phase.pop('time') + return moon_phase raise TypeError('Object of type "%s" could not be integrated in the JSON' % str(type(obj))) @@ -94,5 +99,8 @@ class TextDumper(Dumper): stralign='center', colalign=('left',)) @staticmethod - def get_moon(moon_phase: str) -> str: - return 'Moon phase: %s' % MOON_PHASES[moon_phase] + def get_moon(moon_phase: MoonPhase) -> str: + return 'Moon phase: %s\n' \ + '%s on %s' % (moon_phase.get_phase(), + moon_phase.get_next_phase(), + moon_phase.next_phase_date.utc_strftime('%a %b %-d, %Y %H:%M')) diff --git a/kosmorrolib/ephemerides.py b/kosmorrolib/ephemerides.py index 6e41e6a..debd040 100644 --- a/kosmorrolib/ephemerides.py +++ b/kosmorrolib/ephemerides.py @@ -17,11 +17,13 @@ # along with this program. If not, see . import datetime + from skyfield import almanac from skyfield.timelib import Time +from skyfield.constants import tau -from .data import Object, Position, AsterEphemerides, skyfield_to_moon_phase -from .core import get_skf_objects, get_timescale, ASTERS, MONTHS +from .data import Object, Position, AsterEphemerides, MoonPhase +from .core import get_skf_objects, get_timescale, get_iau2000b, ASTERS, MONTHS, skyfield_to_moon_phase RISEN_ANGLE = -0.8333 @@ -42,13 +44,27 @@ class EphemeridesComputer: return {'rise': sunrise, 'set': sunset} @staticmethod - def get_moon(year, month, day) -> str: + def get_moon_phase(year, month, day) -> MoonPhase: + earth = get_skf_objects()['earth'] + moon = get_skf_objects()['moon'] + sun = get_skf_objects()['sun'] + + def moon_phase_at(time: Time): + time._nutation_angles = get_iau2000b(time) + current_earth = earth.at(time) + _, mlon, _ = current_earth.observe(moon).apparent().ecliptic_latlon('date') + _, slon, _ = current_earth.observe(sun).apparent().ecliptic_latlon('date') + return (((mlon.radians - slon.radians) // (tau / 8)) % 8).astype(int) + + moon_phase_at.rough_period = 7.0 # one lunar phase per week + + today = get_timescale().utc(year, month, day) time1 = get_timescale().utc(year, month, day - 10) - time2 = get_timescale().utc(year, month, day) + time2 = get_timescale().utc(year, month, day + 10) - _, moon_phase = almanac.find_discrete(time1, time2, almanac.moon_phases(get_skf_objects())) + times, phase = almanac.find_discrete(time1, time2, moon_phase_at) - return skyfield_to_moon_phase(moon_phase[-1]) + return skyfield_to_moon_phase(times, phase, today) @staticmethod def get_asters_ephemerides_for_aster(aster, date: datetime.date, position: Position) -> Object: @@ -89,7 +105,7 @@ class EphemeridesComputer: return (year % 4 == 0 and year % 100 > 0) or (year % 400 == 0) def compute_ephemerides_for_day(self, year: int, month: int, day: int) -> dict: - return {'moon_phase': self.get_moon(year, month, day), + return {'moon_phase': self.get_moon_phase(year, month, day), 'details': [self.get_asters_ephemerides_for_aster(aster, datetime.date(year, month, day), self.position) for aster in ASTERS]} diff --git a/test/dumper.py b/test/dumper.py index a922e0e..59a252d 100644 --- a/test/dumper.py +++ b/test/dumper.py @@ -1,13 +1,18 @@ import unittest -from kosmorrolib.data import AsterEphemerides, Planet +from kosmorrolib.data import AsterEphemerides, Planet, MoonPhase from kosmorrolib.dumper import JsonDumper +from kosmorrolib.core import get_timescale class DumperTestCase(unittest.TestCase): def test_json_dumper_returns_correct_json(self): data = self._get_data() self.assertEqual('{\n' - ' "moon_phase": "FULL_MOON",\n' + ' "moon_phase": {\n' + ' "next_phase_date": "2019-11-20T00:00:00Z",\n' + ' "phase": "FULL_MOON",\n' + ' "date": "2019-11-11T00:00:00Z"\n' + ' },\n' ' "details": [\n' ' {\n' ' "name": "Mars",\n' @@ -23,7 +28,7 @@ class DumperTestCase(unittest.TestCase): @staticmethod def _get_data(): return { - 'moon_phase': 'FULL_MOON', + 'moon_phase': MoonPhase('FULL_MOON', get_timescale().utc(2019, 11, 11), get_timescale().utc(2019, 11, 20)), 'details': [Planet('Mars', 'MARS', AsterEphemerides(None, None, None))] } diff --git a/test/ephemerides.py b/test/ephemerides.py index 15eec01..1eef05a 100644 --- a/test/ephemerides.py +++ b/test/ephemerides.py @@ -17,6 +17,70 @@ class EphemeridesComputerTestCase(unittest.TestCase): self.assertEqual('2019-11-18T11:45:02Z', star.ephemerides.culmination_time.utc_iso()) self.assertEqual('2019-11-18T17:48:39Z', star.ephemerides.set_time.utc_iso()) + ################################################################################################################### + ### MOON PHASE TESTS ### + ################################################################################################################### + + def test_moon_phase_new_moon(self): + phase = EphemeridesComputer.get_moon_phase(2019, 11, 25) + self.assertEqual('WANING_CRESCENT', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-26T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 26) + self.assertEqual('NEW_MOON', phase.identifier) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-12-04T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 27) + self.assertEqual('WAXING_CRESCENT', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-12-04T') + + def test_moon_phase_first_crescent(self): + phase = EphemeridesComputer.get_moon_phase(2019, 11, 3) + self.assertEqual('WAXING_CRESCENT', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-04T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 4) + self.assertEqual('FIRST_QUARTER', phase.identifier) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-12T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 5) + self.assertEqual('WAXING_GIBBOUS', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-12T') + + def test_moon_phase_full_moon(self): + phase = EphemeridesComputer.get_moon_phase(2019, 11, 11) + self.assertEqual('WAXING_GIBBOUS', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-12T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 12) + self.assertEqual('FULL_MOON', phase.identifier) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-19T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 13) + self.assertEqual('WANING_GIBBOUS', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-19T') + + def test_moon_phase_last_quarter(self): + phase = EphemeridesComputer.get_moon_phase(2019, 11, 18) + self.assertEqual('WANING_GIBBOUS', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-19T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 19) + self.assertEqual('LAST_QUARTER', phase.identifier) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-26T') + + phase = EphemeridesComputer.get_moon_phase(2019, 11, 20) + self.assertEqual('WANING_CRESCENT', phase.identifier) + self.assertIsNone(phase.time) + self.assertRegexpMatches(phase.next_phase_date.utc_iso(), '^2019-11-26T') + if __name__ == '__main__': unittest.main()