Source code for kanon.calendars.calendars

"""
To create a new `Calendar` you need to subclass it, then instanciate it with an `Era`.
A `Calendar` subclass has to have a name, a list of `Month` and an intercalation
method to be valid.

>>> class NewCal(Calendar):
...     _name = "My New Calendar"
...     _months = [Month(31, 32, "FirstMonth"),
...                Month(20, 22, "SecondMonth"),
...                Month(50, name="ThirdMonth")]
...     def intercalation(self, year: int) -> bool:
...         return year % 7 == 0
>>> my_era = Era("MyEra", 1234)
>>> my_calendar = NewCal(my_era)
>>> my_date = Date(my_calendar, (26, 3, 42), 13.5)
>>> str(my_date)
'42 ThirdMonth 26 MyEra in My New Calendar 13:30'
>>> my_date.jdn
3851.0625
"""

import abc
from dataclasses import dataclass
from functools import cached_property, lru_cache
from typing import Callable, Dict, List, Optional, Tuple, Union

from astropy.time import Time

from kanon.units import BasedReal, Sexagesimal
from kanon.utils.types.number_types import Real

CALENDAR_REGISTRY: Dict[str, "Calendar"] = {}

__all__ = ("Julian", "Byzantine", "Arabic", "Persian", "Egyptian", "Month", "Era")


def hm_to_float(hours: int, minutes: int) -> float:
    """Convert time in hours and minutes to a fraction of a day

    :type hours: int
    :type minutes: int
    :rtype: float
    """
    if not (0 <= hours < 24 and 0 <= minutes < 60):
        raise ValueError("Incorrect time")
    return hours / 24 + minutes / 60 / 24


def float_to_hm(fraction: float) -> Tuple[int, int]:
    """Convert fraction of a day into hours and minutes

    :type fraction: float
    :rtype: float
    """
    if not (0 <= fraction < 1):
        raise ValueError("Incorrect time")

    time = fraction * 24

    hours = int(time)
    mins = int((time - int(time)) * 60)

    return hours, mins


def hours_to_day(hours: Real) -> float:
    """Convert number of hours into fraction of day

    :type fraction: float
    :rtype: float
    """
    return float(hours) / 24


[docs]@dataclass(frozen=True) class Era: """ Dataclass defining an era. >>> Era("A.D.", 1721424) Era(name='A.D.', epoch=1721424) :param name: Name of the era :param days_ly: Start of the era in Julian Day Number """ name: str epoch: float
[docs] def days_from_epoch(self, jdn: float) -> float: return jdn - self.epoch
[docs]@dataclass(frozen=True) class Month: """ Dataclass defining a `~kanon.calendars.Calendar`'s month. >>> Month(28, 29, 'Februarius', ['February']) Month(days_cy=28, days_ly=29, name='Februarius', variant=['February']) :param days_cy: Number of days in a common year :param days_ly: Number of days in a leap year, optional, defaults to `days_cy` value :param name: Name of the month, optional, defaults to "" :param variant: List of name variants of this month, optional """ days_cy: int days_ly: Optional[int] = None name: str = "" variant: Optional[List[str]] = None def _clone_new_leap(self, new_leap): return Month(self.days_cy, new_leap, self.name)
[docs] def days(self, leap=False) -> int: """Returns the month's number of days in common or leap year :param leap: Is it a leap year, defaults to False :rtype: int """ return self.days_ly if leap and self.days_ly else self.days_cy
[docs]class Date: """ Dataclass defining a date. >>> cal = Calendar.registry["Julian A.D."] >>> date = Date(cal, (1,2,3), 13) >>> str(date) '3 Februarius 1 A.D. in Julian 13:00' >>> date.jdn 1721457.0416666667 >>> str(date + 1) '4 Februarius 1 A.D. in Julian 13:00' """ def __init__( self, calendar: "Calendar", ymd: Tuple[int, int, int], hours: Real = Sexagesimal(12), ): """ :param calendar: Calendar used in this date. :type calendar: Calendar :param ymd: Year, month and days, expressed in the specified calendar. :type ymd: Tuple[int, int, int] :param hours: Number of hours, defaults to Sexagesimal(12); :type hours: Real, optional """ if not 0 <= float(hours) < 24: raise ValueError("Time must be in the range [0;24,0[") self._calendar = calendar self._ymd = ymd self._hours = ( hours.resize(1) if isinstance(hours, Sexagesimal) else Sexagesimal.from_float(float(hours), 2) ) self._jdn = calendar.jdn_at_ymd(*ymd) - 0.5 + hours_to_day(hours) @property def calendar(self) -> "Calendar": """Calendar used in this date. :rtype: Calendar """ return self._calendar @property def ymd(self) -> Tuple[int, int, int]: """Year, month and days, expressed in the specified calendar. :rtype: Tuple[int, int, int] """ return self._ymd @property def hours(self) -> BasedReal: """Number of hours :rtype: Sexagesimal """ return self._hours @property def jdn(self) -> float: """Date as a julian day number. :rtype: float """ return self._jdn
[docs] def to_calendar(self, cal: "Calendar") -> "Date": """Express this date in another calendar.""" return cal.from_julian_days(self.jdn)
[docs] def days_from_epoch(self) -> float: """Get number of days from the start of the calendar""" return self.jdn - self.calendar.era.epoch
[docs] def to_time(self) -> Time: """Express this date as a `astropy.time.Time` object with ``jd`` format.""" return Time(self.jdn, format="jd")
def __add__(self, other: Union["Date", Real]) -> "Date": jdn: Real = other.jdn if isinstance(other, Date) else other return self.calendar.from_julian_days(self.jdn + jdn) def __sub__(self, other: Union["Date", Real]) -> "Date": jdn: Real = other.jdn if isinstance(other, Date) else other return self.calendar.from_julian_days(self.jdn - jdn) def __eq__(self, o: object) -> bool: return isinstance(o, Date) and self.jdn == o.jdn def __str__(self): year, month, days = self.ymd h, m = self.hours[0], self.hours[1] return ( f"{days} {self.calendar.months[month-1].name} " f"{year} {self.calendar.era.name} in {self.calendar._name} " f"{'0' if h < 10 else ''}{h}:{'0' if m < 10 else ''}{m}" )
[docs]class Calendar(metaclass=abc.ABCMeta): """This abstract class defines calendar behaviors. You need to subclass this to create a working `Calendar`. You have to define its `interpolation` method, its `_name`, `_months` and maybe `_cycle`. """ #: Registry of all calendars registry: Dict[str, "Calendar"] = CALENDAR_REGISTRY _name: str _months: List[Month] _era: Era _variant: str _cycle: Tuple[int, int] = (1, 0) def __new__( cls, era: Era, variant: str = "", months_mutation: Optional[Callable[[List[Month]], List[Month]]] = None, ) -> "Calendar": """ :param era: Era used by this calendar. :type era: Era :param variant: Name of this variant, defaults to "" :type variant: str, optional :param months_mutation: Function transforming the Calendar class `months` list\ , defaults to None :type months_mutation: Optional[Callable[[List[Month]], List[Month]]], optional :raises ValueError: Raised when the calendar's name has already been used. """ self: Calendar = super().__new__(cls) self._era = era self._variant = variant if self.name in cls.registry: raise ValueError( f"{self.name} already exists in the registry, you might want to" "specify a variant name" ) cls.registry[self.name] = self self._months = (months_mutation or (lambda x: x))(self._months.copy()) return self @property def name(self) -> str: """Name of this calendar :rtype: str """ return f"{self._name} {self._era.name}" + ( f" {self._variant}" if self._variant else "" ) @property def months(self) -> List[Month]: """List of months :rtype: List[Month] """ return self._months @property def cycle(self) -> Tuple[int, int]: """Cycle of common year and leap years (common, leap) :rtype: Tuple[int, int] """ return self._cycle @property def era(self) -> Era: """Calendar era :rtype: Era """ return self._era @cached_property def common_year(self) -> int: """Number of days in a common year :rtype: int """ return sum(m.days_cy for m in self.months) @cached_property def leap_year(self) -> int: """Number of days in a leap year :rtype: int """ return sum(m.days(True) for m in self.months) @cached_property def cycle_length(self) -> int: """Number of days in a leap cycle :rtype: int """ return self.cycle[0] * self.common_year + self.cycle[1] * self.leap_year
[docs] @abc.abstractmethod def intercalation(self, year: int) -> bool: """Is the specified year an intercalation year (leap)""" raise NotImplementedError
[docs] @lru_cache def jdn_at_ymd(self, year: int, month: int, day: int) -> float: """Julian day number at the specified date in ymd""" is_leap = self.intercalation(year) if 0 > month or month > len(self.months): raise ValueError( f"The month entered ({month}) is invalid 1..{len(self.months)}" ) mdn = self.months[month - 1].days(is_leap) if day > mdn or day < 1: raise ValueError( f"The day entered ({day}) is invalid \ in {self.months[month-1].name} 1..{mdn}" ) if year == 0: raise ValueError("Year cannot be zero.") days = 0 negative_year = year < 0 _year_calc = year if negative_year else year - 1 year_rem = abs(_year_calc) % sum(self.cycle) days += sum( self.leap_year if self.intercalation(y) else self.common_year for y in ( range(year - year_rem, year) if not negative_year else range(year, year + year_rem) ) ) days += (abs(_year_calc) // sum(self.cycle)) * self.cycle_length if negative_year: days *= -1 days += sum(m.days(is_leap) for m in self.months[: month - 1]) + day - 1 return days + self.era.epoch
[docs] def get_time(self, year: int, month: int, day: int) -> Time: """`astropy.time.Time` object at the specified date in ymd""" return Time(self.jdn_at_ymd(year, month, day), format="jd")
[docs] @lru_cache def from_julian_days(self, jdn: float) -> Date: """Builds a `Date` object at the specified julian day number.""" time = (jdn - 0.5) % 1 jdn = round(jdn) year = ( int(self.era.days_from_epoch(jdn) * sum(self.cycle) // self.cycle_length) + 1 ) if year < 1: year -= 1 rem = jdn - self.jdn_at_ymd(year, 1, 1) for y in range(year, year + self.cycle_length): ylength = self.leap_year if self.intercalation(y) else self.common_year if rem >= ylength: rem -= ylength year += 1 else: break leap = self.intercalation(year) for i, m in enumerate(self.months): ndays = m.days(leap) if rem < ndays: month = i + 1 days = rem + 1 break else: rem -= ndays return Date(self, (year, month, int(days)), Sexagesimal("24;0") * time)
def __repr__(self) -> str: return f"Calendar({self.name})"
[docs]class Julian(Calendar): """ Defines the Julian Calendar. https://en.wikipedia.org/wiki/Julian_calendar """ _name = "Julian" _months = [ Month(31, 31, "Ianuarius", ["January"]), Month(28, 29, "Februarius", ["February"]), Month(31, 31, "Martius", ["March"]), Month(30, 30, "Aprilis", ["April"]), Month(31, 31, "Maius", ["May"]), Month(30, 30, "Iunius", ["June"]), Month(31, 31, "Iulius", ["July"]), Month(31, 31, "Augustus", ["August"]), Month(30, 30, "September", ["September"]), Month(31, 31, "October", ["October"]), Month(30, 30, "November", ["November"]), Month(31, 31, "December", ["December"]), ] _cycle = (3, 1)
[docs] def intercalation(self, year: int) -> bool: if year < 0: year += 1 return year % 4 == 0
[docs]class Arabic(Calendar): """ Defines the Arabic Calendar. https://en.wikipedia.org/wiki/Islamic_calendar """ _name = "Arabic" _months = [ Month(30, 30, "Muḥarram"), Month(29, 29, "Ṣafar"), Month(30, 30, "Rabīʿ al-awwal"), Month(29, 29, "Rabīʿ al-thānī"), Month(30, 30, "Jumādā l-ūlā"), Month(29, 29, "Jumādā l-thāniyya"), Month(30, 30, "Rajab"), Month(29, 29, "Shaʿbān"), Month(30, 30, "Ramaḍān"), Month(29, 29, "Shawwāl"), Month(30, 30, "Dhū l-qaʿda"), Month(29, 30, "Dhū l-ḥijja"), ] _cycle = (19, 11)
[docs] def intercalation(self, year: int) -> bool: return (1 + (year + 29) % 30) in {2, 5, 7, 10, 13, 16, 18, 21, 24, 26, 29}
[docs]class Byzantine(Calendar): """ Defines the Byzantine Calendar. https://en.wikipedia.org/wiki/Byzantine_calendar """ _name = "Byzantine" _months = [ Month(31, 31, "Adhār"), Month(30, 30, "Nisān"), Month(31, 31, "Ayyār"), Month(30, 30, "Ḥazirān"), Month(31, 31, "Tammūz"), Month(31, 31, "Āb"), Month(30, 30, "Aylūl"), Month(31, 31, "Tishrīn al-awwal"), Month(30, 30, "Tishrīn al-thānī"), Month(31, 31, "Kānūn al-awwal"), Month(31, 31, "Kānūn al-thānī"), Month(28, 29, "Shubāṭ"), ] _cycle = (3, 1)
[docs] def intercalation(self, year: int) -> bool: return (year - 1) % 4 == 0
[docs]class Egyptian(Calendar): """ Defines the Egyptian Calendar. https://en.wikipedia.org/wiki/Egyptian_calendar """ _name = "Egyptian" _months = [ Month(30, name="Thoth"), Month(30, name="Phaophi"), Month(30, name="Athyr"), Month(30, name="Choiak"), Month(30, name="Tybi"), Month(30, name="Mechir"), Month(30, name="Phamenoth"), Month(30, name="Pharmuthi"), Month(30, name="Pachon"), Month(30, name="Payni"), Month(30, name="Epiphi"), Month(30, name="Mesore"), Month(5, name="Epagomenai"), ]
[docs] def intercalation(self, year: int) -> bool: return False
[docs]class Persian(Calendar): """ Defines the Persian Calendar. https://en.wikipedia.org/wiki/Zoroastrian_calendar """ _name = "Persian" _months = [ Month(30, name="Farwardīn"), Month(30, name="Urdībihisht"), Month(30, name="Khurdādh"), Month(30, name="Tīr"), Month(30, name="Murdādh"), Month(30, name="Shahrīwar"), Month(30, name="Mihr"), Month(30, name="Ābān"), Month(30, name="Ādhar"), Month(30, name="Day"), Month(30, name="Bahman"), Month(30, name="Isfandārmudh"), Month(5, name="Andarjah"), ]
[docs] def intercalation(self, year: int) -> bool: return False
# Arabic Calendars Arabic(Era("Civil Hijra", 1948440)) Arabic(Era("Astronomical Hijra", 1948439)) # Egyptian Calendars Egyptian(Era("Nabonassar", 1448638)) Egyptian(Era("Philippus", 1603398)) _anno_domini = Era("A.D.", 1721424) # Julian Calendars Julian(_anno_domini) def _leap_december(months): months[1] = months[1]._clone_new_leap(28) months[-1] = months[-1]._clone_new_leap(32) return months Julian(_anno_domini, variant="Leap December", months_mutation=_leap_december) Julian( _anno_domini, variant="First month March", months_mutation=lambda m: m[2:] + m[:2] ) Julian(Era("Julian Era", 0)) # Byzantine Calendars Byzantine(_anno_domini) # Persian Calendars _yazdigird = Era("Yazdigird", 1952063) Persian(_yazdigird, variant="Andarjah at the end") Persian( _yazdigird, variant="Andarjah after Ābān", months_mutation=lambda m: m[:8] + [m[-1]] + m[8:-1], )