Source code for pylunar.moon_info

# This file is part of pylunar.
#
# Developed by Michael Reuter.
#
# See the LICENSE file at the top-level directory of this distribution
# for details of code ownership.
#
# Use of this source code is governed by a 3-clause BSD-style
# license that can be found in the LICENSE file.

"""Module for the MoonInfo class."""

from __future__ import annotations

__all__ = ["MoonInfo"]

from datetime import datetime
from enum import Enum
import math
from operator import itemgetter

import ephem
import pytz

from .helpers import mjd_to_date_tuple, tuple_to_string
from .lunar_feature import LunarFeature
from .pkg_types import DateTimeTuple, DmsCoordinate, MoonPhases


class PhaseName(Enum):
    """Phase names for the lunar cycle."""

    NEW_MOON = 0
    WAXING_CRESCENT = 1
    FIRST_QUARTER = 2
    WAXING_GIBBOUS = 3
    FULL_MOON = 4
    WANING_GIBBOUS = 5
    LAST_QUARTER = 6
    WANING_CRESCENT = 7


class TimeOfDay(Enum):
    """Time of day from the lunar terminator."""

    MORNING = 0
    EVENING = 1


[docs] class MoonInfo: """Handle all moon information. Attributes ---------- observer : ephem.Observer instance. The instance containing the observer's location information. moon : ephem.Moon instance The instance of the moon object. Parameters ---------- latitude : tuple of 3 ints The latitude of the observer in GPS DMS(Degrees, Minutes and Seconds) format. longitude : tuple of 3 ints The longitude of the observer in GPS DMS(Degrees, Minutes and Seconds) format. name : str, optional A name for the observer's location. """ DAYS_TO_HOURS = 24.0 MAIN_PHASE_CUTOFF = 2.0 # Time cutoff (hours) around the NM, FQ, FM, and LQ phases FEATURE_CUTOFF = 15.0 # The offset (degrees) from the colongitude used for visibility check NO_CUTOFF_TYPE = ("Landing Site", "Mare", "Oceanus") # Feature types that are not subject to longitude cutoffs LIBRATION_ZONE = 80.0 # Latitude and/or longitude where librations have a big effect MAXIMUM_LIBRATION_PHASE_ANGLE_CUTOFF = 65.0 # The maximum value of the libration phase angle difference for a feature reverse_phase_lookup = { "new_moon": (ephem.previous_last_quarter_moon, "last_quarter"), "first_quarter": (ephem.previous_new_moon, "new_moon"), "full_moon": (ephem.previous_first_quarter_moon, "first_quarter"), "last_quarter": (ephem.previous_full_moon, "full_moon"), } def __init__(self, latitude: DmsCoordinate, longitude: DmsCoordinate, name: str | None = None): self.observer = ephem.Observer() self.observer.lat = tuple_to_string(latitude) self.observer.long = tuple_to_string(longitude) self.moon = ephem.Moon()
[docs] def age(self) -> float: """Lunar age in days. Returns ------- float The lunar age. """ prev_new = ephem.previous_new_moon(self.observer.date) return float(self.observer.date - prev_new)
[docs] def fractional_age(self) -> float: """Lunar fractional age which is always less than 1.0. Returns ------- float The fractional lunar age. """ prev_new = ephem.previous_new_moon(self.observer.date) next_new = ephem.next_new_moon(self.observer.date) return float((self.observer.date - prev_new) / (next_new - prev_new))
[docs] def altitude(self) -> float: """Lunar altitude in degrees. Returns ------- float The lunar altitiude. """ return math.degrees(self.moon.alt)
[docs] def angular_size(self) -> float: """Lunar current angular size in degrees. Returns ------- float The lunar angular size. """ moon_size: float = self.moon.size return moon_size / 3600.0
[docs] def azimuth(self) -> float: """Lunar azimuth in degrees. Returns ------- float The lunar azimuth. """ return math.degrees(self.moon.az)
[docs] def colong(self) -> float: """Lunar selenographic colongitude in degrees. Returns ------- float The lunar seleographic colongitude. """ return math.degrees(self.moon.colong)
[docs] def dec(self) -> float: """Lunar current declination in degrees. Returns ------- float The lunar declination. """ return math.degrees(self.moon.dec)
[docs] def earth_distance(self) -> float: """Lunar current distance from the earth in km. Returns ------- float THe earth-moon distance. """ return float(self.moon.earth_distance * ephem.meters_per_au / 1000.0)
[docs] def elongation(self) -> float: """Lunar elongation from the sun in degrees. Returns ------- float The lunar solar elongation. """ elongation = math.degrees(self.moon.elong) if elongation < 0: elongation += 360.0 return elongation
[docs] def fractional_phase(self) -> float: """Lunar fractional illumination which is always less than 1.0. Returns ------- float The lunar fractional phase. """ return float(self.moon.moon_phase)
[docs] def libration_lat(self) -> float: """Lunar current latitudinal libration in degrees. Returns ------- float The lunar libration latitude. """ return math.degrees(self.moon.libration_lat)
[docs] def libration_lon(self) -> float: """Lunar current longitudinal libration in degrees. Returns ------- float The lunar libration longitude. """ return math.degrees(self.moon.libration_long)
[docs] def libration_phase_angle(self) -> float: """Phase angle of lunar current libration in degrees. Returns ------- float The lunar libration phase angle. """ phase_angle = math.atan2(self.moon.libration_long, self.moon.libration_lat) phase_angle += 2.0 * math.pi if phase_angle < 0 else 0.0 return math.degrees(phase_angle)
[docs] def magnitude(self) -> float: """Lunar current magnitude. Returns ------- float The lunar magnitude. """ return float(self.moon.mag)
[docs] def colong_to_long(self) -> float: """Selenographic longitude in degrees based on the terminator. Returns ------- float The lunar seleographic longitude. """ colong: float = self.colong() if 90.0 <= colong < 270.0: longitude = 180.0 - colong elif 270.0 <= colong < 360.0: longitude = 360.0 - colong else: longitude = -colong return longitude
[docs] def is_libration_ok(self, feature: LunarFeature) -> bool: """Determine if lunar feature is visible due to libration effect. Parameters ---------- feature : :class:`.LunarFeature` The lunar feature instance to check. Returns ------- bool True if visible, False if not. """ is_lon_in_zone = math.fabs(feature.longitude) > self.LIBRATION_ZONE is_lat_in_zone = math.fabs(feature.latitude) > self.LIBRATION_ZONE if is_lat_in_zone or is_lon_in_zone: feature_angle = feature.feature_angle() libration_phase_angle = self.libration_phase_angle() delta_phase_angle = libration_phase_angle - feature_angle delta_phase_angle -= 360.0 if delta_phase_angle > 180.0 else 0.0 return math.fabs(delta_phase_angle) <= self.MAXIMUM_LIBRATION_PHASE_ANGLE_CUTOFF return True
[docs] def is_visible(self, feature: LunarFeature) -> bool: """Determine if lunar feature is visible. Parameters ---------- feature : :class:`.LunarFeature` The lunar feature instance to check. Returns ------- bool True if visible, False if not. """ selco_lon = self.colong_to_long() current_tod = self.time_of_day() min_lon = feature.longitude - feature.delta_longitude / 2 max_lon = feature.longitude + feature.delta_longitude / 2 if min_lon > max_lon: min_lon, max_lon = max_lon, min_lon is_visible = False latitude_scaling = math.cos(math.radians(feature.latitude)) if feature.feature_type not in MoonInfo.NO_CUTOFF_TYPE: cutoff = MoonInfo.FEATURE_CUTOFF / latitude_scaling else: cutoff = MoonInfo.FEATURE_CUTOFF if current_tod == TimeOfDay.MORNING.name: # Minimum longitude for morning visibility lon_cutoff = min_lon - cutoff if feature.feature_type in MoonInfo.NO_CUTOFF_TYPE: is_visible = selco_lon <= min_lon else: is_visible = lon_cutoff <= selco_lon <= min_lon else: # Maximum longitude for evening visibility lon_cutoff = max_lon + cutoff if feature.feature_type in MoonInfo.NO_CUTOFF_TYPE: is_visible = max_lon <= selco_lon else: is_visible = max_lon <= selco_lon <= lon_cutoff return is_visible and self.is_libration_ok(feature)
[docs] def next_four_phases(self) -> MoonPhases: """Next four phases in date sorted order (closest phase first). Returns ------- list[(str, float)] Set of lunar phases specified by an abbreviated phase name and Modified Julian Date. """ phases = {} phases["new_moon"] = ephem.next_new_moon(self.observer.date) phases["first_quarter"] = ephem.next_first_quarter_moon(self.observer.date) phases["full_moon"] = ephem.next_full_moon(self.observer.date) phases["last_quarter"] = ephem.next_last_quarter_moon(self.observer.date) sorted_phases = sorted(phases.items(), key=itemgetter(1)) sorted_phases = [(phase[0], mjd_to_date_tuple(phase[1])) for phase in sorted_phases] return sorted_phases
[docs] def phase_name(self) -> str: """Return standard name of lunar phase, i.e. Waxing Cresent. This function returns a standard name for lunar phase based on the current selenographic colongitude. Returns ------- str The lunar phase name. """ next_phase_name = self.next_four_phases()[0][0] try: next_phase_time = getattr(ephem, f"next_{next_phase_name}")(self.observer.date) except AttributeError: next_phase_time = getattr(ephem, f"next_{next_phase_name}_moon")(self.observer.date) previous_phase = self.reverse_phase_lookup[next_phase_name] time_to_next_phase = math.fabs(next_phase_time - self.observer.date) * self.DAYS_TO_HOURS time_to_previous_phase = ( math.fabs(self.observer.date - previous_phase[0](self.observer.date)) * self.DAYS_TO_HOURS ) previous_phase_name = previous_phase[1] phase_name = "" if time_to_previous_phase < self.MAIN_PHASE_CUTOFF: phase_name = getattr(PhaseName, previous_phase_name.upper()).name elif time_to_next_phase < self.MAIN_PHASE_CUTOFF: phase_name = getattr(PhaseName, next_phase_name.upper()).name else: if previous_phase_name == "new_moon" and next_phase_name == "first_quarter": phase_name = PhaseName.WAXING_CRESCENT.name elif previous_phase_name == "first_quarter" and next_phase_name == "full_moon": phase_name = PhaseName.WAXING_GIBBOUS.name elif previous_phase_name == "full_moon" and next_phase_name == "last_quarter": phase_name = PhaseName.WANING_GIBBOUS.name elif previous_phase_name == "last_quarter" and next_phase_name == "new_moon": phase_name = PhaseName.WANING_CRESCENT.name return phase_name
[docs] def phase_shape_in_ascii(self) -> str: """Display lunar phase shape in ASCII art. This function returns a multi-line string demonstrate current lunar shape in ASCII format. Returns ------- str The lunar phase shape. """ phase = self.phase_name() if phase == PhaseName.NEW_MOON.name: return """ _..._ .:::::::. ::::::::::: ::::::::::: `:::::::::' `':::'' """ elif phase == PhaseName.WAXING_CRESCENT.name: return """ _..._ .::::. `. :::::::. : :::::::: : `::::::' .' `'::'-' """ elif phase == PhaseName.FIRST_QUARTER.name: return """ _..._ .:::: `. :::::: : :::::: : `::::: .' `'::.-' """ elif phase == PhaseName.WAXING_GIBBOUS.name: return """ _..._ .::' `. ::: : ::: : `::. .' `':..-' """ elif phase == PhaseName.FULL_MOON.name: return """ _..._ .' `. : : : : `. .' `-...-' """ elif phase == PhaseName.WANING_GIBBOUS.name: return """ _..._ .' `::. : ::: : ::: `. .::' `-..:'' """ elif phase == PhaseName.LAST_QUARTER.name: return """ _..._ .' ::::. : :::::: : :::::: `. :::::' `-.::'' """ elif phase == PhaseName.WAXING_CRESCENT.name: return """ _..._ .' .::::. : :::::::: : :::::::: `. '::::::' `-.::'' """ else: return phase
[docs] def phase_emoji(self) -> str: """Return standard emoji of lunar phase, i.e. '🌒'. This function returns a standard emoji for lunar phase based on the current selenographic colongitude. Returns ------- str The lunar phase emoji. """ return { "NEW_MOON": "🌑", "WAXING_CRESCENT": "🌒", "FIRST_QUARTER": "🌓", "WAXING_GIBBOUS": "🌔", "FULL_MOON": "🌕", "WANING_GIBBOUS": "🌖", "LAST_QUARTER": "🌗", "WANING_CRESCENT": "🌘", }[self.phase_name()]
[docs] def ra(self) -> float: """Lunar current right ascension in degrees. Returns ------- float The lunar right ascension. """ return math.degrees(self.moon.ra)
[docs] def rise_set_times(self, timezone: str) -> MoonPhases: """Calculate the rise, set and transit times in the local time system. Parameters ---------- timezone : str The timezone identifier for the calculations. Returns ------- list[(str, tuple)] Set of rise, set, and transit times in the local time system. If event does not happen, 'Does not xxx' is tuple value. """ utc = pytz.utc try: tz = pytz.timezone(timezone) except pytz.UnknownTimeZoneError: tz = utc func_map = {"rise": "rising", "transit": "transit", "set": "setting"} # Need to set observer's horizon and pressure to get times old_pressure = self.observer.pressure old_horizon = self.observer.horizon self.observer.pressure = 0 self.observer.horizon = "-0:34" current_date_utc = datetime(*mjd_to_date_tuple(self.observer.date, round_off=True), tzinfo=utc) # type: ignore current_date = current_date_utc.astimezone(tz) current_day = current_date.day times = {} does_not = None for time_type in ("rise", "transit", "set"): mjd_time = getattr(self.observer, "{}_{}".format("next", func_map[time_type]))(self.moon) utc_time = datetime(*mjd_to_date_tuple(mjd_time, round_off=True), tzinfo=utc) # type: ignore local_date = utc_time.astimezone(tz) if local_date.day == current_day: times[time_type] = local_date else: mjd_time = getattr(self.observer, "{}_{}".format("previous", func_map[time_type]))(self.moon) utc_time = datetime(*mjd_to_date_tuple(mjd_time, round_off=True), tzinfo=utc) # type: ignore local_date = utc_time.astimezone(tz) if local_date.day == current_day: times[time_type] = local_date else: does_not = (time_type, f"Does not {time_type}") # Return observer and moon to previous state self.observer.pressure = old_pressure self.observer.horizon = old_horizon self.moon.compute(self.observer) original_sorted_times = sorted(times.items(), key=itemgetter(1)) sorted_times: MoonPhases = [(xtime[0], xtime[1].timetuple()[:6]) for xtime in original_sorted_times] if does_not is not None: sorted_times.insert(0, does_not) return sorted_times
[docs] def subsolar_lat(self) -> float: """Latitude in degress on the moon where the sun is overhead. Returns ------- float The lunar subsolar latitude. """ return math.degrees(self.moon.subsolar_lat)
[docs] def time_of_day(self) -> str: """Terminator time of day. This function determines if the terminator is sunrise (morning) or sunset (evening). Returns ------- str The lunar time of day. """ colong = self.colong() if 90.0 <= colong < 270.0: return TimeOfDay.EVENING.name else: return TimeOfDay.MORNING.name
[docs] def time_from_new_moon(self) -> float: """Time (hours) from the previous new moon. This function calculates the time from the previous new moon. Returns ------- float The time from new moon. """ previous_new_moon = ephem.previous_new_moon(self.observer.date) return float(MoonInfo.DAYS_TO_HOURS * (self.observer.date - previous_new_moon))
[docs] def time_to_full_moon(self) -> float: """Time (days) to the next full moon. This function calculates the time to the next full moon. Returns ------- float The time to full moon. """ next_full_moon = ephem.next_full_moon(self.observer.date) return float(next_full_moon - self.observer.date)
[docs] def time_to_new_moon(self) -> float: """Time (hours) to the next new moon. This function calculates the time to the next new moon. Returns ------- float The time to new moon. """ next_new_moon = ephem.next_new_moon(self.observer.date) return float(MoonInfo.DAYS_TO_HOURS * (next_new_moon - self.observer.date))
[docs] def update(self, datetime: DateTimeTuple) -> None: """Update the moon information based on time. This fuction updates the Observer instance's datetime setting. The incoming datetime tuple should be in UTC with the following placement of values: (YYYY, m, d, H, M, S) as defined below:: YYYY Four digit year m month (1-12) d day (1-31) H hours (0-23) M minutes (0-59) S seconds (0-59) Parameters ---------- datetime : tuple The current UTC time in a tuple of numbers. """ self.observer.date = datetime self.moon.compute(self.observer)