Source code for absfuyu.util.lunar

# flake8: noqa
"""
Absfuyu: Lunar calendar
-----------------------
Convert to lunar calendar

Version: 5.1.0
Date updated: 10/03/2025 (dd/mm/yyyy)

Source:
-------
Astronomical algorithms from the book "Astronomical Algorithms" by Jean Meeus, 1998
https://www.informatik.uni-leipzig.de/~duc/amlich/AL.py
"""

# Module level
# ---------------------------------------------------------------------------
__all__ = ["LunarCalendar"]


# Library
# ---------------------------------------------------------------------------
import math
from datetime import date, datetime


# Class
# ---------------------------------------------------------------------------
[docs] class LunarCalendar: """Lunar Calendar""" def __init__( self, year: int, month: int, day: int, *, time_zone: int = 7, lunar_leap: bool = False, ) -> None: """ :param time_zone: Time zone :param lunar_leap: Do not use this """ self.date = date(year, month, day) self.time_zone = time_zone self._lunar_leap = lunar_leap def __str__(self) -> str: return f"{self.__class__.__name__}({self.date}|lunar_leap={self._lunar_leap})" def __repr__(self) -> str: return self.__str__() def _julian_day_from_date( self, *, overwrite_year: int | None = None, overwrite_month: int | None = None, overwrite_day: int | None = None, ) -> int: """ Compute the (integral) Julian day number of `self.date` i.e., the number of days between 1/1/4713 BC (Julian calendar) and `self.date`. """ day = self.date.day if overwrite_day is None else overwrite_day month = self.date.month if overwrite_month is None else overwrite_month year = self.date.year if overwrite_year is None else overwrite_year a = int((14 - month) / 12.0) y = year + 4800 - a m = month + 12 * a - 3 jd = ( day + int((153 * m + 2) / 5.0) + 365 * y + int(y / 4.0) - int(y / 100.0) + int(y / 400.0) - 32045 ) if jd < 2299161: jd = day + int((153 * m + 2) / 5.0) + 365 * y + int(y / 4.0) - 32083 return jd @staticmethod def _julian_day_to_date(julian_day: int) -> date: """ Convert a Julian day number to `datetime.date` julian_day: Julian day """ if julian_day > 2299160: ## After 5/10/1582, Gregorian calendar a = julian_day + 32044 b = int((4 * a + 3) / 146097.0) c = a - int((b * 146097) / 4.0) else: b = 0 c = julian_day + 32082 d = int((4 * c + 3) / 1461.0) e = c - int((1461 * d) / 4.0) m = int((5 * e + 2) / 153.0) day = e - int((153 * m + 2) / 5.0) + 1 month = m + 3 - 12 * int(m / 10.0) year = b * 100 + d - 4800 + int(m / 10.0) return date(year, month, day) @staticmethod def _new_moon(k: int) -> float: """ Compute the time of the k-th new moon after the new moon of `1/1/1900 13:52 UCT` measured as the number of days since `1/1/4713 BC noon UCT`, e.g., `2451545.125 is 1/1/2000 15:00 UTC`. Example: `2415079.9758617813` for `k=2` or `2414961.935157746` for `k=-2` """ ## Time in Julian centuries from 1900 January 0.5 T = k / 1236.85 T2 = math.pow(T, 2) T3 = math.pow(T, 3) dr = math.pi / 180.0 Jd1 = 2415020.75933 + 29.53058868 * k + 0.0001178 * T2 - 0.000000155 * T3 Jd1 = Jd1 + 0.00033 * math.sin((166.56 + 132.87 * T - 0.009173 * T2) * dr) ## Mean new moon M = 359.2242 + 29.10535608 * k - 0.0000333 * T2 - 0.00000347 * T3 ## Sun's mean anomaly Mpr = 306.0253 + 385.81691806 * k + 0.0107306 * T2 + 0.00001236 * T3 ## Moon's mean anomaly F = 21.2964 + 390.67050646 * k - 0.0016528 * T2 - 0.00000239 * T3 ## Moon's argument of latitude C1 = (0.1734 - 0.000393 * T) * math.sin(M * dr) + 0.0021 * math.sin(2 * dr * M) C1 = C1 - 0.4068 * math.sin(Mpr * dr) + 0.0161 * math.sin(dr * 2 * Mpr) C1 = C1 - 0.0004 * math.sin(dr * 3 * Mpr) C1 = C1 + 0.0104 * math.sin(dr * 2 * F) - 0.0051 * math.sin(dr * (M + Mpr)) C1 = ( C1 - 0.0074 * math.sin(dr * (M - Mpr)) + 0.0004 * math.sin(dr * (2 * F + M)) ) C1 = ( C1 - 0.0004 * math.sin(dr * (2 * F - M)) - 0.0006 * math.sin(dr * (2 * F + Mpr)) ) C1 = ( C1 + 0.0010 * math.sin(dr * (2 * F - Mpr)) + 0.0005 * math.sin(dr * (2 * Mpr + M)) ) if T < -11: deltat = ( 0.001 + 0.000839 * T + 0.0002261 * T2 - 0.00000845 * T3 - 0.000000081 * T * T3 ) else: deltat = -0.000278 + 0.000265 * T + 0.000262 * T2 JdNew = Jd1 + C1 - deltat return JdNew @staticmethod def _sun_longitude(julian_day_noon: float) -> float: """ Compute the longitude of the sun at any time. julian_day_noon: The number of days since `1/1/4713 BC noon`. """ T = (julian_day_noon - 2451545.0) / 36525.0 # Time in Julian centuries # from 2000-01-01 12:00:00 GMT T2 = math.pow(T, 2) T3 = math.pow(T, 3) dr = math.pi / 180.0 # degree to radian M = 357.52910 + 35999.05030 * T - 0.0001559 * T2 - 0.00000048 * T3 # mean anomaly, degree L0 = 280.46645 + 36000.76983 * T + 0.0003032 * T2 # mean longitude, degree DL = (1.914600 - 0.004817 * T - 0.000014 * T2) * math.sin(dr * M) DL += (0.019993 - 0.000101 * T) * math.sin(dr * 2 * M) + 0.000290 * math.sin( dr * 3 * M ) L = L0 + DL # true longitude, degree L = L * dr L = L - math.pi * 2 * (int(L / (math.pi * 2))) # Normalize to (0, 2*math.pi) return L def _get_sun_longitude(self, day_number: int) -> int: """ Compute sun position at midnight of the day with the given Julian day number. The time zone if the time difference between local time and UTC: 7.0 for UTC+7:00. The function returns a number between `0` and `11`. From the day after March equinox and the 1st major term after March equinox, 0 is returned. After that, return 1, 2, 3 ... """ return int( self._sun_longitude(day_number - 0.5 - self.time_zone / 24.0) / math.pi * 6 ) def _get_new_moon_day(self, k: int) -> int: """ Compute the day of the k-th new moon in the given time zone. The time zone if the time difference between local time and UTC: 7.0 for UTC+7:00. """ return int(self._new_moon(k) + 0.5 + self.time_zone / 24.0) def _get_lunar_month_11(self, *, overwrite_year: int | None = None) -> int: """ Find the day that starts the luner month 11 of the given year for the given time zone. """ # off = self._julian_day_from_date(overwrite_month=12, overwrite_day=31) - 2415021.076998695 year = self.date.year if overwrite_year is None else overwrite_year off = ( self._julian_day_from_date( overwrite_month=12, overwrite_day=31, overwrite_year=year ) - 2415021.0 ) k = int(off / 29.530588853) nm = self._get_new_moon_day(k) sunLong = self._get_sun_longitude(nm) #### sun longitude at local midnight if sunLong >= 9: nm = self._get_new_moon_day(k - 1) return nm def _get_leap_month_offset(self, a11: int) -> int: """ Find the index of the leap month after the month starting on the day a11. """ k = int((a11 - 2415021.076998695) / 29.530588853 + 0.5) last = 0 i = 1 # start with month following lunar month 11 arc = self._get_sun_longitude(self._get_new_moon_day(k + i)) while True: last = arc i += 1 arc = self._get_sun_longitude(self._get_new_moon_day(k + i)) if not (arc != last and i < 14): break # for i in range(1, 14): # arc = self._get_sun_longitude(self._get_new_moon_day(k + i)) # if arc != last: # last = arc # else: # break return i - 1
[docs] def to_lunar(self): """ Convert solar date to the corresponding lunar date. Returns ------- LunarCalendar LunarCalendar instance """ yy = self.date.year day_number = self._julian_day_from_date() k = int((day_number - 2415021.076998695) / 29.530588853) month_start = self._get_new_moon_day(k + 1) if month_start > day_number: month_start = self._get_new_moon_day(k) # alert(day_number + " -> " + month_start) a11 = self._get_lunar_month_11() b11 = a11 if a11 >= month_start: lunar_year = yy a11 = self._get_lunar_month_11(overwrite_year=yy - 1) else: lunar_year = yy + 1 b11 = self._get_lunar_month_11(overwrite_year=yy + 1) lunar_day = day_number - month_start + 1 diff = int((month_start - a11) / 29.0) lunar_month = diff + 11 if b11 - a11 > 365: leap_month_diff = self._get_leap_month_offset(a11) if diff >= leap_month_diff: lunar_month = diff + 10 if diff == leap_month_diff: self._lunar_leap = True if lunar_month > 12: lunar_month = lunar_month - 12 if lunar_month >= 11 and diff < 4: lunar_year -= 1 return self.__class__( lunar_year, lunar_month, lunar_day, lunar_leap=self._lunar_leap )
[docs] def to_solar(self): """ Convert a lunar date to the corresponding solar date. Returns ------- LunarCalendar LunarCalendar instance date When unable to convert """ lunarD = self.date.day lunarM = self.date.month lunarY = self.date.year if lunarM < 11: a11 = self._get_lunar_month_11(overwrite_year=lunarY - 1) b11 = self._get_lunar_month_11(overwrite_year=lunarY) else: a11 = self._get_lunar_month_11(overwrite_year=lunarY) b11 = self._get_lunar_month_11(overwrite_year=lunarY + 1) k = int(0.5 + (a11 - 2415021.076998695) / 29.530588853) off = lunarM - 11 if off < 0: off += 12 if b11 - a11 > 365: leapOff = self._get_leap_month_offset(a11) leapM = leapOff - 2 if leapM < 0: leapM += 12 if self._lunar_leap is True and lunarM != leapM: # logger.warning("lunar_leap is True") # raise ValueError() return date(1, 1, 1) elif self._lunar_leap is True or off >= leapOff: off += 1 monthStart = self._get_new_moon_day(k + off) out = self._julian_day_to_date(monthStart + lunarD - 1) return self.__class__(out.year, out.month, out.day, lunar_leap=self._lunar_leap)
[docs] def convert_all(self) -> dict: """ Convert to both lunar and solar This method has no practical use :rtype: dict """ return {"lunar": self.to_lunar(), "original": self, "solar": self.to_solar()}
[docs] @classmethod def from_solar(cls, year: int, month: int, day: int, lunar_leap: bool = False): """ Convert to lunar day from solar day Parameters ---------- year : int Year (in range [1, 9999]) month : int Month (in range [1, 12]) day : int Day (in range [1, 31]) lunar_leap : bool Is leap (Default: ``False``) Returns ------- LunarCalendar LunarCalendar instance """ return cls(year, month, day, lunar_leap=lunar_leap).to_lunar()
[docs] @classmethod def from_datetime(cls, datetime_object: date | datetime): """ Convert from ``datetime.datetime`` object Parameters ---------- datetime_object : date | datetime ``datetime.datetime`` object Returns ------- LunarCalendar LunarCalendar instance """ return cls(datetime_object.year, datetime_object.month, datetime_object.day)
[docs] @classmethod def now(cls): """ Get lunar calendar for ``datetime.datetime.now()`` Returns ------- LunarCalendar LunarCalendar instance """ today = datetime.now().date() return cls(today.year, today.month, today.day).to_lunar()