Source code for hebrewcal.calendars_alt.karaite

"""The Karaite calendar — an astronomical estimate of the observational calendar.

.. warning::

   The authentic Karaite calendar is **observational**: each month begins with the
   first naked-eye sighting of the new crescent over the Land of Israel, and the
   year is intercalated by the ripeness of the spring barley (aviv). Neither can be
   reduced to a formula, and actual practice depends on reports from observers.

   This module provides an **astronomical estimate**, not the authentic calendar:

   - A month begins on the evening of the first *estimated* crescent visibility over
     Jerusalem — modelled as the first sunset, at or after the true lunar conjunction
     (see :mod:`hebrewcal.astro.lunar`), at which the moon is at least
     :data:`MIN_MOON_AGE_HOURS` hours old. This is a simple age criterion, **not** a
     full first-visibility model (it ignores the moon's altitude and elongation).
   - The year begins with the month whose 15th day (Passover) is the first on or
     after the vernal equinox — a standard *approximation* of the aviv rule.

   The underlying astronomy (true conjunction, sunset, equinox) is verified, but the
   resulting calendar is **not** validated against actual Karaite practice and must
   not be used to determine observance.
"""

from __future__ import annotations

import datetime
from dataclasses import dataclass
from functools import lru_cache

from hebrewcal.astro.location import Location
from hebrewcal.astro.lunar import nth_new_moon
from hebrewcal.astro.solar import solar_declination, sunset
from hebrewcal.calendars.gregorian import GregorianDate

# Jerusalem, the reference location for crescent visibility.
JERUSALEM = Location(31.7683, 35.2137, elevation=754.0, timezone="Asia/Jerusalem")

# Minimum moon age at sunset for the crescent to be treated as visible. A young
# crescent below ~15-24 hours is generally not seen; 20 hours is a middle value.
MIN_MOON_AGE_HOURS: float = 20.0

# Karaite year Y is aligned with the spring of Gregorian year (Y - _AM_OFFSET).
_AM_OFFSET = 3760


def _estimate_lunation(rd: int) -> int:
    jd = rd + 1721424.5
    return round((jd - 2451550.09766) / 29.530588861)


@lru_cache(maxsize=8192)
def _month_start_rd(lunation: int) -> int:
    """Return the RD of day 1 of the month for the given true-conjunction lunation.

    Day 1 is the daytime following the evening of estimated first visibility.
    """
    conjunction = nth_new_moon(lunation)
    local = conjunction.astimezone(JERUSALEM.tzinfo)
    base = GregorianDate(local.year, local.month, local.day).to_rd()
    threshold = datetime.timedelta(hours=MIN_MOON_AGE_HOURS)
    for offset in range(0, 3):
        evening = GregorianDate.from_rd(base + offset)
        dusk = sunset(evening, JERUSALEM, elevation=True)
        if dusk is not None and dusk - conjunction >= threshold:
            return base + offset + 1
    return base + 2  # pragma: no cover - visibility is always found within 3 days


@lru_cache(maxsize=4096)
def _march_equinox_rd(gregorian_year: int) -> int:
    """Return the RD of the March (vernal) equinox, to day precision."""
    best_rd = GregorianDate(gregorian_year, 3, 20).to_rd()
    best = abs(solar_declination(gregorian_year, 3, 20))
    for day in range(17, 24):
        value = abs(solar_declination(gregorian_year, 3, day))
        if value < best:
            best = value
            best_rd = GregorianDate(gregorian_year, 3, day).to_rd()
    return best_rd


@lru_cache(maxsize=4096)
def _aviv_lunation(year: int) -> int:
    """Return the lunation index of Aviv (month 1) for the Karaite ``year``."""
    equinox = _march_equinox_rd(year - _AM_OFFSET)
    lunation = _estimate_lunation(equinox) - 3
    # Aviv is the first month whose 15th day (start + 14) is on or after the equinox.
    while _month_start_rd(lunation) + 14 < equinox:
        lunation += 1
    while _month_start_rd(lunation - 1) + 14 >= equinox:
        lunation -= 1
    return lunation


[docs] def months_in_year(year: int) -> int: """Return the number of months in the Karaite ``year`` (12 or 13).""" return _aviv_lunation(year + 1) - _aviv_lunation(year)
[docs] def last_day_of_month(year: int, month: int) -> int: """Return the number of days in ``month`` of ``year`` (29 or 30).""" lunation = _aviv_lunation(year) + (month - 1) return _month_start_rd(lunation + 1) - _month_start_rd(lunation)
[docs] @dataclass(frozen=True, order=True) class KaraiteDate: """A date in the Karaite astronomical-estimate calendar.""" year: int month: int day: int def __post_init__(self) -> None: if not 1 <= self.month <= months_in_year(self.year): raise ValueError(f"month out of range: {self.month}") if not 1 <= self.day <= last_day_of_month(self.year, self.month): raise ValueError(f"day out of range: {self.day}")
[docs] def to_rd(self) -> int: """Return the Rata Die day count for this date.""" lunation = _aviv_lunation(self.year) + (self.month - 1) return _month_start_rd(lunation) + self.day - 1
[docs] @classmethod def from_rd(cls, rd: int) -> KaraiteDate: """Reconstruct a Karaite date from an RD value.""" lunation = _estimate_lunation(rd) while _month_start_rd(lunation) > rd: lunation -= 1 while _month_start_rd(lunation + 1) <= rd: lunation += 1 year = GregorianDate.from_rd(rd).year + _AM_OFFSET while _aviv_lunation(year) > lunation: year -= 1 while _aviv_lunation(year + 1) <= lunation: year += 1 month = lunation - _aviv_lunation(year) + 1 day = rd - _month_start_rd(lunation) + 1 return cls(year, month, day)