diff --git a/pyproject.toml b/pyproject.toml index 2fe5385697..ce1263001e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ scripts.holidays-ics = "holidays.generate_ics:main" [dependency-groups] dev = [ + "ephem>=4.2.1,<5", "gitpython>=3.1.50,<4", "hijridate>=2.5,<3", "lingva>=5.0.6,<6", diff --git a/scripts/calendar/hindu_generator.py b/scripts/calendar/hindu_generator.py new file mode 100644 index 0000000000..d8038e431c --- /dev/null +++ b/scripts/calendar/hindu_generator.py @@ -0,0 +1,1876 @@ +#!/usr/bin/env python3 + +# holidays +# -------- +# A fast, efficient Python library for generating country, province and state +# specific sets of holidays on the fly. It aims to make determining whether a +# specific date is a holiday as fast and flexible as possible. +# +# Authors: Vacanza Team and individual contributors (see CONTRIBUTORS file) +# dr-prodigy (c) 2017-2023 +# ryanss (c) 2014-2017 +# Website: https://github.com/vacanza/holidays +# License: MIT (see LICENSE file) + +"""Generate Gregorian dates for holidays based on the Hindu lunisolar calendar. + +Run with: + + python -m scripts.calendar.hindu_generator + +Alternatively, run with uv: + + uv run -m scripts.calendar.hindu_generator + +This generates the files: + + * `holidays/calendars/hindu_dates.py` + +whose data can then be copied to: + + * `holidays/calendars/hindu.py` +""" + +import math +from collections import defaultdict +from datetime import date, timedelta +from functools import cache + +import ephem + +from .generator import CalendarGenerator + +# Coordinates for Ujjain, India (holy city used in Hindu astrology) +LAT = "23.1765" +LON = "75.7885" + +_DUBLIN_TO_JD = 2415020.0 + +_LAHIRI_J2000 = 23.85045 # degrees at J2000.0 (JD 2451545.0) +_PRECESSION_RATE = 50.2388475 / 3600 # degrees per Julian year + + +class _Astronomy: + """Astronomical helper functions for Hindu calendar calculations.""" + + _sun = ephem.Sun() + _moon = ephem.Moon() + + def __init__(self) -> None: + self._observer = ephem.Observer() + self._observer.lat = LAT + self._observer.lon = LON + self._observer.elevation = 0 + self._observer.pressure = 0 + + @staticmethod + def _lahiri_ayanamsa(ephem_date: ephem.Date) -> float: + """Return Lahiri ayanamsa in degrees for the given pyephem Date.""" + jd = float(ephem_date) + _DUBLIN_TO_JD + years_from_j2000 = (jd - 2451545.0) / 365.25 + return _LAHIRI_J2000 + _PRECESSION_RATE * years_from_j2000 + + @staticmethod + def _norm360(x: float) -> float: + return x % 360 + + def _set_observer_date(self, dt: date) -> None: + """Point the shared observer to the start of dt (UTC).""" + self._observer.date = ephem.Date(dt) + + def _tropical_lon(self, body: ephem.Body, ephem_date: ephem.Date) -> float: + """Return the tropical (apparent) ecliptic longitude of body in degrees.""" + body.compute(ephem_date) + ecl = ephem.Ecliptic(body, epoch=ephem_date) + return math.degrees(ecl.lon) % 360 + + @cache + def _sunrise(self, dt: date) -> ephem.Date: + """Returns sunrise on dt at Ujjain.""" + self._set_observer_date(dt) + return self._observer.next_rising(self._sun) + + @cache + def _sunset(self, dt: date) -> ephem.Date: + """Returns sunset on dt at Ujjain.""" + self._set_observer_date(dt) + return self._observer.next_setting(self._sun) + + def _sidereal_solar_zodiac_sign(self, ed: ephem.Date) -> int: + """Return the sidereal zodiac sign index (0 = Aries … 11 = Pisces) of the + Sun at the given date.""" + trop_lon = self._tropical_lon(self._sun, ed) + ayanamsa = self._lahiri_ayanamsa(ed) + sid_lon = self._norm360(trop_lon - ayanamsa) + return int(sid_lon // 30) + + +class _Lunisolar(_Astronomy): + """Convert dates from the Hindu lunisolar calendar to Gregorian dates. + + Sources: + - https://web.archive.org/web/20251204101508/https://deadseaquake.info/pdfs/RD2018.pdf + - https://web.archive.org/web/20251207235328/https://www.drikpanchang.com + """ + + # Lunar months (named after the zodiac sign of sun at sunset of Amavasya (new moon)) + # LUNAR_MONTH_NAMES = [ + # "Vaishakh", # (0°-30°) - Aries (0) + # "Jyeshth", # (30°-60°) - Taurus (1) + # "Ashadh", # (60°-90°) - Gemini (2) + # "Shravan", # (90°-120°) - Cancer (3) + # "Bhadrapad", # (120°-150°) - Leo (4) + # "Ashwin", # (150°-180°) - Virgo (5) + # "Kartik", # (180°-210°) - Libra (6) + # "Margashirsh", # (210°-240°) - Scorpio (7) + # "Paush", # (240°-270°) - Sagittarius (8) + # "Magh", # (270°-300°) - Capricorn (9) + # "Phalgun", # (300°-330°) - Aquarius (10) + # "Chaitra", # (330°-360°) - Pisces (11) + # ] + + @cache + def _midnight(self, dt: date) -> ephem.Date: + """Return Nishita Kaal (midpoint of sunset -> next sunrise).""" + ss = self._sunset(dt) + sr = self._sunrise(dt + timedelta(days=1)) + return ephem.Date((float(ss) + float(sr)) / 2) + + @cache + def _aparahna(self, dt: date) -> ephem.Date: + """Return Aparahna (4th of 5 equal day-parts) on dt.""" + sr = self._sunrise(dt) + ss = self._sunset(dt) + return ephem.Date(float(sr) + (3 / 5) * (float(ss) - float(sr))) + + @cache + def _madhyahna(self, dt: date) -> ephem.Date: + """Return Madhyahna (midpoint of sunrise -> sunset) on dt.""" + sr = self._sunrise(dt) + ss = self._sunset(dt) + return ephem.Date((float(sr) + float(ss)) / 2) + + def _tithi(self, ed: ephem.Date) -> int: + """Return the tithi (1-30) at the given date. + + Tithis are based on tropical longitudes (the ayanamsa cancels out in the + difference moon - sun). + """ + sun_lon = self._tropical_lon(self._sun, ed) + moon_lon = self._tropical_lon(self._moon, ed) + return int(self._norm360(moon_lon - sun_lon) // 12) + 1 + + @cache + def _get_amavasya( + self, + start: date, + zodiac_sign: int, + *, + last: bool = False, + sign_boundary_guard: bool = False, + ) -> date | None: + """ + Find Amavasya (tithi 30 or skipped 29->1) while sun is in the given + sidereal zodiac sign, scanning forward from start date. + + If last=False (default): returns the first Amavasya found. + If last=True: returns the last Amavasya before the sun leaves the sign. + Use this when a leap month (Adhik Maas) can place two Amavasyas + in the same zodiac sign and you need the real (second) one. + + If sign_boundary_guard=False (default): skipped Amavasya (29->1) is + accepted regardless of where the sun was the previous day. + If sign_boundary_guard=True: skipped Amavasya is only accepted if the + previous day's sunset was also in zodiac_s zign. This prevents a + new moon straddling a sign boundary from being misattributed to + the wrong lunar month. + """ + last_found = None + for delta in range(60): + dt = start + timedelta(days=delta) + ss = self._sunset(dt) + sign = self._sidereal_solar_zodiac_sign(ss) + + if sign == (zodiac_sign + 1) % 12: + break + + if sign != zodiac_sign: + continue + + t = self._tithi(ss) + if t == 30: + found = dt + elif t == 1 and self._tithi(ss_prev := self._sunset(dt - timedelta(days=1))) == 29: + if ( + sign_boundary_guard + and self._sidereal_solar_zodiac_sign(ss_prev) != zodiac_sign + ): + continue # previous day was a different sign - reject. + found = dt + else: + continue + if not last: + return found + last_found = found + + return last_found + + # Amavasya -> use SUN's sidereal sign -> determines lunar month + # Purnima -> use MOON's sidereal sign -> determines lunar month + # (moon is in the nakshatra the month is named after) + + def get_anant_chaturdashi(self, year: int) -> date | None: + """ + Anant Chaturdashi = Bhadrapada Shukla Chaturdashi. + Tithi=14 (Chaturdashi) of Shukla Paksha in Bhadrapada month-sun in sidereal Leo (sign 4). + Evaluated at sunrise (Udaya tithi rule). + + Chaturdashi detection: + - Present at sunrise -> track last occurrence + - Skipped between days (13 -> 15) -> return current day + - Ended (14 -> 15+) -> return last recorded day + """ + # Find Bhadrapada Amavasya + bhadrapada_ama = self._get_amavasya(date(year, 8, 1), zodiac_sign=4, last=True) + + if not bhadrapada_ama: + return None + + # Find last Chaturdashi (tithi 14) at sunrise, or skipped case (13 -> 15) + last_chaturdashi = None + for delta in range(12, 17): + dt = bhadrapada_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 14: + last_chaturdashi = dt + elif t == 15 and t_prev == 13: + return dt + elif last_chaturdashi: + break + + return last_chaturdashi + + def get_basant_panchami(self, year: int) -> date | None: + """ + Basant Panchami (Saraswati Puja) = Magh Shukla Panchami. + Tithi = 5 (Panchami) of Shukla Paksha in Magh month - sun in sidereal Capricorn (sign 9). + Evaluated at Madhyahna (midday, midpoint of sunrise-sunset). + + Panchami detection (Madhyahna tithi): + - First occurrence of tithi 5 (normal or skip-over-4) → return that day + - Tithi 5 entirely skipped (4→6) → return that day + """ + exceptions = { + 2023: date(2023, 1, 26), + } + if year in exceptions: + return exceptions[year] + + # Find Magh Amavasya + magh_ama = self._get_amavasya(date(year, 1, 1), zodiac_sign=9) + + if not magh_ama: + return None + + for delta in range(1, 10): + dt = magh_ama + timedelta(days=delta) + t = self._tithi(self._madhyahna(dt)) + t_prev = self._tithi(self._madhyahna(dt - timedelta(days=1))) + + if t == 5: + return dt + if t == 6 and t_prev == 4: + return dt + + return None + + def get_bathukamma(self, year: int) -> date | None: + """ + Bathukamma = Mahalaya Amavasya. + Tithi = 30 (Amavasya) of Ashwin month - sun in sidereal Virgo (sign 5). + The Mahalaya Amavasya date is determined by the Amavasya tithi + occurring during the day. + """ + exceptions = { + 2032: date(2032, 10, 4), + 2033: date(2033, 9, 23), + } + if year in exceptions: + return exceptions[year] + + # Find Mahalaya Amavasya (Ashwin Amavasya). + return self._get_amavasya(date(year, 9, 1), zodiac_sign=5, sign_boundary_guard=True) + + def get_bonalu(self, year: int) -> date | None: + """ + Bonalu = Telangana Ashadh Masam festival. + The ending date is the Monday following the final Sunday + of Ashadh Masam. + """ + # Find the Amavasya ending Ashadh Masam. + ashad_ama = self._get_amavasya(date(year, 7, 1), zodiac_sign=3) + + if not ashad_ama: + return None + + # Find the final Sunday before Ashadh Amavasya. + dt = ashad_ama - timedelta(days=1) + + while dt.weekday() != 6: + dt -= timedelta(days=1) + + # Bonalu ending date is the following Monday. + return dt + timedelta(days=1) + + def get_buddha_purnima(self, year: int) -> date | None: + """ + Buddha Purnima = Vaishakha Shukla Purnima. + Tithi = 15 (Purnima) of Vaishakha month - sun in sidereal Aries (sign 0). + Evaluated at sunrise (Udaya tithi rule). + + Purnima detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (14 -> 16) -> return previous day + """ + # Find Vaishakha Amavasya. + vaishakha_ama = self._get_amavasya(date(year, 4, 1), zodiac_sign=0) + + if not vaishakha_ama: + return None + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16). + for delta in range(12, 18): + dt = vaishakha_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + return dt + + if t == 16 and t_prev == 14: + return dt - timedelta(days=1) + + return None + + def get_chaitra_navratri(self, year: int) -> date | None: + """ + Chaitra Navratri begins on Chaitra Shukla Pratipada. + Tithi = 1 (Pratipada) of Shukla Paksha in Chaitra month - preceding + the sidereal transition of the sun from Pisces (sign 11) to Aries (sign 0). + Evaluated at sunrise (Udaya tithi rule). + + In Adhika Masa (leap month) years, two Pratipadas can occur before + the sun enters Aries. The last Pratipada before Mesha Sankranti belongs + to Chaitra proper. + + Pratipada detection (sunrise tithi): + - Present at sunrise (tithi 1) -> return the first occurrence + - Skipped between sunrises (30 -> 2) -> return current day + """ + # Find Mesha Sankranti (sun enters sidereal Aries) + mesha_sankranti = None + + for delta in range(20): + dt = date(year, 4, 1) + timedelta(days=delta) + sign = self._sidereal_solar_zodiac_sign(self._sunset(dt)) + sign_prev = self._sidereal_solar_zodiac_sign(self._sunset(dt - timedelta(days=1))) + + if sign == 0 and sign_prev == 11: + mesha_sankranti = dt + break + + if not mesha_sankranti: + return None + + candidate = None + + # Find the last Pratipada before Mesha Sankranti + for delta in range(45): + dt = mesha_sankranti - timedelta(days=44 - delta) + + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + # First sunrise with Pratipada active (t == 1) + # or Pratipada skipped (t == 2 and t_prev == 30) -> candidate + if (t == 1 and t_prev != 1) or (t == 2 and t_prev == 30): + candidate = dt + + return candidate + + def get_chhath_puja(self, year: int) -> date | None: + """ + Chhath Puja = Kartik Shukla Shashthi. + Tithi = 6 (Shashthi) of Shukla Paksha in Kartik month - sun in sidereal Libra (sign 6). + Evaluated at sunrise (Udaya tithi rule). + + Shashthi detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (5 -> 7) -> return current day + """ + exceptions = { + 2025: date(2025, 10, 28), + } + if year in exceptions: + return exceptions[year] + + # Find Kartik Amavasya + kartik_ama = self._get_amavasya(date(year, 10, 15), zodiac_sign=6) + + if not kartik_ama: + return None + + # Find Shashthi (tithi 6) at sunrise, or skipped case (5 -> 7) + for delta in range(4, 8): + dt = kartik_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 6: + return dt + if t == 7 and t_prev == 5: + return dt + + return None + + def get_dattatreya_jayanti(self, year: int) -> date | None: + """ + Dattatreya Jayanti = Margashirsha Purnima. + Tithi = 15 (Purnima) of Margashirsha month - sun in sidereal Scorpio (sign 7). + Evaluated at sunset (Pradosh rule). + + Purnima detection: + - First sunset with Purnima active -> return that day + - Skipped between sunsets (14 -> 16) -> return current day + """ + exceptions = { + 2001: date(2001, 12, 29), + 2015: date(2015, 12, 24), + 2034: date(2034, 12, 24), + } + if year in exceptions: + return exceptions[year] + + # Find Margashirsha Amavasya. + margashirsha_ama = self._get_amavasya(date(year, 11, 1), zodiac_sign=7) + + if not margashirsha_ama: + return None + + # Find first sunset with Purnima (tithi 15), or skipped case (14 -> 16). + for delta in range(12, 18): + dt = margashirsha_ama + timedelta(days=delta) + t = self._tithi(self._sunset(dt)) + t_prev = self._tithi(self._sunset(dt - timedelta(days=1))) + + # Purnima active during Pradosh. + if t == 15 and t_prev != 15: + return dt + + # Purnima skipped entirely between two sunsets. + if t == 16 and t_prev == 14: + return dt + + return None + + def get_dev_diwali(self, year: int) -> date | None: + """ + Dev Diwali = Kartik Purnima. + Tithi = 15 (Purnima) of Kartik month - sun in sidereal Libra (sign 6). + Evaluated at sunset (evening rule). + + Purnima detection (sunset tithi): + - Present at sunset -> return the last such day + - Skipped between days (14 -> 16) -> return previous day + """ + # Find Kartik Amavasya + kartik_ama = self._get_amavasya(date(year, 10, 1), zodiac_sign=6) + + if not kartik_ama: + return None + + purnima_dates = [] + + # Find Purnima (tithi 15) at sunset + for delta in range(13, 18): + dt = kartik_ama + timedelta(days=delta) + t = self._tithi(self._sunset(dt)) + t_prev = self._tithi(self._sunset(dt - timedelta(days=1))) + + if t == 15: + purnima_dates.append(dt) + + elif t == 16 and t_prev == 14: + purnima_dates.append(dt - timedelta(days=1)) + + # If Purnima spans two sunsets, Dev Diwali follows the later occurrence + return purnima_dates[-1] if purnima_dates else None + + def get_diwali(self, year: int) -> date | None: + """ + Diwali = Kartik Amavasya. + Tithi = 30 (new moon) of Kartik month - sun in sidereal Libra (sign 6). + Evaluated at sunset (pradosh rule). + """ + return self._get_amavasya(date(year, 10, 15), zodiac_sign=6) + + def get_dussehra(self, year: int) -> date | None: + """ + Dussehra = Ashwin Shukla Dashami. + Tithi = 10 (Dashami) of Shukla Paksha in Ashwin month - sun in sidereal Virgo (sign 5). + Evaluated at Aparahna (afternoon rule). + + Dashami detection: + - Present at Aparahna -> return that day (first occurrence) + - Skipped between days (9 -> 11) -> return current day + """ + exceptions = { + 2003: date(2003, 10, 5), + 2019: date(2019, 10, 8), + } + if year in exceptions: + return exceptions[year] + + # Find Ashwin Amavasya + ashwin_ama = self._get_amavasya(date(year, 9, 18), zodiac_sign=5) + + if not ashwin_ama: + return None + + # Find Dashami (tithi 10) at Aparahna, or skipped case (9 -> 11). + for delta in range(9, 12): + dt = ashwin_ama + timedelta(days=delta) + t = self._tithi(self._aparahna(dt)) + if t == 10 or (t == 11 and self._tithi(self._aparahna(dt - timedelta(days=1))) == 9): + return dt + + return None + + def get_ganesh_chaturthi(self, year: int) -> date | None: + """ + Ganesh Chaturthi = Bhadrapada Shukla Chaturthi. + Tithi = 4 (Chaturthi) of Bhadrapada month - sun in sidereal Leo (sign 4). + Evaluated at Madhyahna (midday rule). + + Chaturthi detection: + - Present at Madhyahna -> return that day + - Skipped between middays (3 -> 5) -> return current day + """ + # Find last Bhadrapada Amavasya + bhadra_ama = self._get_amavasya(date(year, 8, 1), zodiac_sign=4, last=True) + + if not bhadra_ama: + return None + + # Find Chaturthi (tithi 4) at Madhyahna, or skipped case (3 -> 5) + for delta in range(10): + dt = bhadra_ama + timedelta(days=delta) + t = self._tithi(self._madhyahna(dt)) + + if t == 4 or (t == 5 and self._tithi(self._madhyahna(dt - timedelta(days=1))) == 3): + return dt + + return None + + def get_govardhan_puja(self, year: int) -> date | None: + """ + Govardhan Puja = Kartik Shukla Pratipada. + + Tithi = 1 (Pratipada) of the Shukla Paksha following Diwali + (Kartik Amavasya). Evaluated using the Udaya Tithi (sunrise) rule. + + Reference Point: + - Find Diwali (Kartik Amavasya). + - Govardhan Puja is observed on the last day Pratipada is active + at sunrise after Diwali. + + Three sunrise cases for Pratipada detection: + 1: Pratipada active at sunrise (tithi 1) + -> keep updating candidate (we want the last occurrence) + 2: Amavasya at sunrise but Pratipada at sunset (30->1) + -> current day is Govardhan Puja + 3: Pratipada skipped between sunrises (30->2) + -> current day is Govardhan Puja + """ + exceptions = { + 2007: date(2007, 11, 10), + 2026: date(2026, 11, 10), + } + if year in exceptions: + return exceptions[year] + + diwali = self.get_diwali(year) + + if diwali is None: + return None + + last_pratipada = None + for delta in range(1, 5): + dt = diwali + timedelta(days=delta) + t_sr = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + t_ss = self._tithi(self._sunset(dt)) + + # Pratipada active at sunrise -> candidate + if t_sr == 1: + last_pratipada = dt + + # Amavasya at sunrise but Pratipada at sunset -> this night is Govardhan Puja + # or Pratipada skipped between sunrises (30->2) + elif (t_sr == 30 and t_ss == 1) or (t_sr == 2 and t_prev == 30): + return dt + + # Pratipada ended + elif last_pratipada is not None: + return last_pratipada + + return last_pratipada + + def get_gudi_padwa(self, year: int) -> date | None: + """ + Gudi Padwa = Chaitra Shukla Pratipada. + Tithi = 1 (Pratipada) of Shukla Paksha in Chaitra month - sun in sidereal Pisces (sign 11). + Evaluated at sunrise (Udaya tithi rule) - first occurrence. + + According to Hindu scriptures, the Udaya tithi (tithi prevailing at sunrise) + governs the day. If Pratipada occurs on the sunrise of two days, the first + day is Gudi Padwa. It marks the Marathi New Year and the start of Chaitra. + + Two sunrise cases for Pratipada detection: + 1: Pratipada active at sunrise (tithi 1) - current day is Gudi Padwa + 2: Pratipada skipped entirely between sunrises (30->2) - meaning Pratipada + started after previous sunrise and ended before current sunrise, + so the previous day is Gudi Padwa + """ + start = date(year, 3, 10) + for delta in range(50): + dt = start + timedelta(days=delta) + ss = self._sunset(dt) + if self._sidereal_solar_zodiac_sign(ss) != 11: + continue + t_sr = self._tithi(self._sunrise(dt)) + t_sr_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + # Pratipada active at sunrise (Udaya tithi) - first occurrence is Gudi Padwa + if t_sr == 1: + return dt + + # Pratipada skipped entirely between sunrises (30->2) + # Pratipada occurred during previous day → previous day is Gudi Padwa + if t_sr == 2 and t_sr_prev == 30: + return dt - timedelta(days=1) + + return None + + def get_guru_nanak_jayanti(self, year: int) -> date | None: + """ + Guru Nanak Jayanti = Kartik Purnima. + Tithi = 15 (Purnima) of Kartik month - sun in sidereal Libra (sign 6). + Evaluated at sunrise (Udaya rule). + + Purnima detection: + - First sunrise with Purnima active -> return that day + - Skipped between sunrises (14 -> 16) -> return previous day + """ + # Find Kartik Amavasya + kartik_ama = self._get_amavasya(date(year, 10, 1), zodiac_sign=6) + if not kartik_ama: + return None + + # Find first sunrise with Purnima (tithi 15), or skipped case (14 -> 16) + for delta in range(20): + dt = kartik_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15 and t_prev != 15: + return dt + if t == 16 and t_prev == 14: + return dt - timedelta(days=1) + + return None + + def get_guru_purnima(self, year: int) -> date | None: + """ + Guru Purnima = Ashadh Purnima. + Tithi = 15 (Purnima) of Shukla Paksha in Ashadh month - sun in sidereal Gemini (sign 2). + Evaluated at sunrise (Udaya tithi rule). + + In Adhika Masa (leap month) years, two Purnimas can occur while + the sun is in sidereal Gemini. The last one belongs to Ashadh proper. + + Purnima detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (14 -> 16) -> return current day + """ + # Find last Ashadh Amavasya + ashada_ama = self._get_amavasya(date(year, 6, 1), zodiac_sign=2, last=True) + + if not ashada_ama: + return None + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16) + for delta in range(12, 18): + dt = ashada_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + return dt + if t == 16 and t_prev == 14: + return dt + + return None + + def get_guru_ravidas_jayanti(self, year: int) -> date | None: + """ + Guru Ravidas Jayanti = Magha Purnima. + Tithi = 15 (Purnima) of Magha month - sun in sidereal Capricorn (sign 9). + Evaluated at sunrise (Udaya tithi rule). + + Purnima detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (14 -> 16) -> return previous day + """ + # Find Magha Amavasya. + magha_ama = self._get_amavasya(date(year, 1, 1), zodiac_sign=9) + + if not magha_ama: + return None + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16). + for delta in range(12, 18): + dt = magha_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + return dt + + if t == 16 and t_prev == 14: + return dt - timedelta(days=1) + + return None + + def get_hanuman_jayanti(self, year: int) -> date | None: + """ + Hanuman Jayanti = Chaitra Purnima. + Tithi = 15 (Purnima) of Shukla Paksha in Chaitra month - sun in sidereal Pisces (sign 11). + Evaluated at sunrise (Udaya tithi rule). + + Purnima detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (14 -> 16) -> return current day + """ + # Find Chaitra Amavasya + chaitra_ama = self._get_amavasya(date(year, 3, 1), zodiac_sign=11) + + if not chaitra_ama: + return None + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16) + for delta in range(12, 18): + dt = chaitra_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + return dt + if t == 16 and t_prev == 14: + return dt + + return None + + def get_hariyali_amavasya(self, year: int) -> date | None: + """ + Hariyali Amavasya = Shravan Amavasya. + Tithi = 30 (Amavasya) in Shravan month - sun in sidereal Cancer (sign 3). + Evaluated at sunrise (Udaya tithi rule). + + Amavasya detection: + - Present at sunrise -> return that day + - Skipped between days (29 -> 1) -> return current day + """ + # Find Shravan Amavasya + shravan_ama = self._get_amavasya(date(year, 7, 1), zodiac_sign=3) + + if not shravan_ama: + return None + + # Find Amavasya (tithi 30) at sunrise, or skipped case (29 -> 1) + for delta in range(-1, 3): + dt = shravan_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 30: + return dt + if t == 1 and t_prev == 29: + return dt + + return None + + def get_holi(self, year: int) -> date | None: + """ + Holi = Phalgun Purnima (Rangwali Holi, day after Holika Dahan). + Tithi = 15 (Purnima) of Phalgun month - sun in sidereal Aquarius (sign 10). + Evaluated at sunset (Pradosh rule). + + Reference: Phalgun Amavasya (sign 10), fallback to Magh Amavasya (sign 9). + + Purnima end detection (sunset tithi): + - Skipped entirely (14 -> 16) -> return current day + - Spanned 2 sunsets (16 -> 16) -> return current day + - Tithi 16 skipped (15 -> 17+) -> return current day + - Past tithi 16 (16 -> 17+) -> return previous day + """ + exceptions = { + 2026: date(2026, 3, 4), + 2029: date(2029, 3, 1), + } + if year in exceptions: + return exceptions[year] + + # Find Phalgun Amavasya, fallback to Magh Amavasya + phalgun_ama = self._get_amavasya( + date(year, 1, 15), zodiac_sign=10, sign_boundary_guard=True + ) + magh_ama = self._get_amavasya(date(year, 1, 15), zodiac_sign=9, sign_boundary_guard=True) + + # Ensure magh_ama is only used as a fallback and precedes phalgun_ama + if magh_ama and phalgun_ama and magh_ama >= phalgun_ama: + magh_ama = None + + ref = phalgun_ama or magh_ama + if not ref: + return None + + # Find Purnima end after anchor + for delta in range(20): + dt = ref + timedelta(days=delta) + t = self._tithi(self._sunset(dt)) + t_prev = self._tithi(self._sunset(dt - timedelta(days=1))) + + # skipped or spanned 2 sunsets + if t == 16 and t_prev in (14, 16): + return dt + # tithi 16 skipped entirely + if t > 16 and t_prev == 15: + return dt + # moved past 16 + if t > 16 and t_prev == 16: + return dt - timedelta(days=1) + + return None + + def get_janmashtami(self, year: int) -> date | None: + """ + Janmashtami = Bhadrapad Krishna Paksha Ashtami. + Tithi = 23 (Krishna Ashtami) of Bhadrapada month - sun in sidereal Leo (sign 4). + Evaluated at sunrise (Udaya rule). + + Reference: Bhadrapad Amavasya (tithi 30, sign 4); search 5-15 days before it. + + Ashtami detection (sunrise tithi): + - Ended overnight (23 -> 24+) -> return previous day + - Skipped entirely (<23 -> >23) -> return current day + """ + # Find Bhadrapad Amavasya + bhadrapad_ama = self._get_amavasya(date(year, 8, 1), zodiac_sign=4) + + if not bhadrapad_ama: + return None + + # Search 5-15 days before Amavasya for Ashtami + for delta in range(15, 2, -1): + dt = bhadrapad_ama - timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + # ended overnight + if t > 23 and t_prev == 23: + return dt - timedelta(days=1) + # skipped entirely + if t > 23 and t_prev < 23: + return dt + + return None + + def get_kabir_jayanti(self, year: int) -> date | None: + """ + Kabir Jayanti = Jyeshtha Purnima. + Tithi = 15 (Purnima) of Jyeshtha month - sun in sidereal Taurus (sign 1). + Evaluated at sunrise (Udaya tithi rule). + + In Adhika Masa (leap month) years, two consecutive Jyeshtha lunar + months can occur. The second Purnima belongs to Jyeshtha proper. + + Purnima detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (14 -> 16) -> return current day + """ + # Find Jyeshtha Amavasya + jyeshtha_ama = self._get_amavasya(date(year, 5, 1), zodiac_sign=1) + + if not jyeshtha_ama: + return None + + # Check for another Jyeshtha Amavasya in Adhika Masa + next_jyeshtha_ama = self._get_amavasya(jyeshtha_ama + timedelta(days=1), zodiac_sign=1) + + if next_jyeshtha_ama and next_jyeshtha_ama - jyeshtha_ama <= timedelta(days=31): + jyeshtha_ama = next_jyeshtha_ama + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16) + for delta in range(12, 18): + dt = jyeshtha_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + return dt + if t == 16 and t_prev == 14: + return dt + + return None + + def get_karwa_chauth(self, year: int) -> date | None: + """ + Karwa Chauth = Kartik Krishna Chaturthi. + Tithi = 19 (Chaturthi) of Krishna Paksha in Kartik month. + Evaluated at moonrise (evening/moonrise tithi rule). + + Chaturthi detection (moonrise tithi): + - Present at moonrise -> return that day + - Skipped between moonrises (18 -> 20) -> return current day + """ + exceptions = { + 2002: date(2002, 10, 24), + 2003: date(2003, 10, 13), + } + if year in exceptions: + return exceptions[year] + + # Find Kartik Amavasya + diwali = self.get_diwali(year) + + if not diwali: + return None + + # Find Krishna Chaturthi immediately before Kartik Amavasya. + for delta in range(8, 15): + dt = diwali - timedelta(days=delta) + + self._set_observer_date(dt) + moonrise = self._observer.next_rising(self._moon) + + t = self._tithi(moonrise) + + self._set_observer_date(dt - timedelta(days=1)) + moonrise_prev = self._observer.next_rising(self._moon) + + t_prev = self._tithi(moonrise_prev) + + if t == 19: + return dt + + if t == 20 and t_prev == 18: + return dt + + return None + + def get_maha_ashtami(self, year: int) -> date | None: + """ + Maha Ashtami = Ashwin Shukla Ashtami. + Tithi = 8 (Ashtami) of Shukla Paksha in Ashwin month - sun in sidereal Virgo (sign 5). + Evaluated at sunrise (Udaya tithi rule). + + Ashtami detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (7 -> 9) -> return current day + """ + exceptions = { + 2020: date(2020, 10, 23), + } + if year in exceptions: + return exceptions[year] + + # Find Ashwin Amavasya + ashwin_ama = self._get_amavasya(date(year, 9, 5), zodiac_sign=5, sign_boundary_guard=True) + + if not ashwin_ama: + return None + + # Find Ashtami (tithi 8) at sunrise, or skipped case (7 -> 9) + for delta in range(7, 12): + dt = ashwin_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + + if t == 8 or (t == 9 and self._tithi(self._sunrise(dt - timedelta(days=1))) == 7): + return dt + + return None + + def get_maha_navami(self, year: int) -> date | None: + """ + Maha Navami = Ashwin Shukla Navami. + Tithi = 9 (Navami) of Shukla Paksha in Ashwin month - sun in sidereal Virgo (sign 5). + Evaluated at Aparahna (afternoon rule). + + Navami detection (Aparahna tithi): + - Present at Aparahna (9) -> return current day + - Skipped between Aparahnas (8 -> 10) -> return previous day + """ + # Find Ashwin Amavasya + ashwin_ama = self._get_amavasya(date(year, 9, 5), zodiac_sign=5, sign_boundary_guard=True) + + if not ashwin_ama: + return None + + # Find Navami (tithi 9) at Aparahna, or skipped case (8 -> 10) + for delta in range(8, 12): + dt = ashwin_ama + timedelta(days=delta) + t = self._tithi(self._aparahna(dt)) + t_prev = self._tithi(self._aparahna(dt - timedelta(days=1))) + + if t == 9: + return dt + if t == 10 and t_prev == 8: + return dt - timedelta(days=1) + + return None + + def get_maha_shivaratri(self, year: int) -> date | None: + """ + Maha Shivaratri = Phalgun Krishna Chaturdashi. + Tithi = 29 active at sunset or midnight, sun in Aquarius (sign 10) or Capricorn (sign 9). + + Selection rule: + - Phalgun on Mar 13+ -> use Magh + - Phalgun on Mar 12 & Magh Feb 12 -> use Magh + - Otherwise -> use Phalgun + """ + # Find Magh and Phalgun Chaturdashi + start = date(year, 1, 15) + magh_chaturdashi = phalgun_chaturdashi = None + + for delta in range(75): + dt = start + timedelta(days=delta) + ss = self._sunset(dt) + sign = self._sidereal_solar_zodiac_sign(ss) + + if sign > 10 and phalgun_chaturdashi: + break + + t_ss = self._tithi(ss) + t_mid = self._tithi(self._midnight(dt)) + is_chaturdashi = t_ss == 29 or t_mid == 29 + + if is_chaturdashi and sign == 9 and not magh_chaturdashi: + magh_chaturdashi = dt + + if is_chaturdashi and sign in (10, 11) and not phalgun_chaturdashi: + sign_prev = self._sidereal_solar_zodiac_sign(self._sunset(dt - timedelta(days=1))) + if sign == 11 and sign_prev == 10: + # sign_prev is 10 (Aquarius), so the dt-1 shift condition (sign_prev==9) + # from the original can never fire here - always use dt + phalgun_chaturdashi = dt + elif sign == 10: + t_mid_prev = self._tithi(self._midnight(dt - timedelta(days=1))) + sign_prev_day = self._sidereal_solar_zodiac_sign( + self._sunset(dt - timedelta(days=1)) + ) + if t_mid_prev == 29 and sign_prev_day == 9: + phalgun_chaturdashi = dt - timedelta(days=1) + else: + phalgun_chaturdashi = dt + + if not phalgun_chaturdashi: + return None + + # Apply selection rule + if magh_chaturdashi: + p, m = phalgun_chaturdashi, magh_chaturdashi + if (p.month == 3 and p.day >= 13) or ( + p.month == 3 and p.day == 12 and m.month == 2 and m.day == 12 + ): + return magh_chaturdashi + + return phalgun_chaturdashi + + def get_maharana_pratap_jayanti(self, year: int) -> date | None: + """ + Maharana Pratap Jayanti = Jyeshtha Shukla Tritiya. + Tithi = 3 (Tritiya) of Jyeshtha month - sun in sidereal Taurus (sign 1). + Evaluated at sunrise (Udaya tithi rule). + + In Adhika Masa (leap month) years, two consecutive Jyeshtha lunar + months can occur. The second Tritiya belongs to Jyeshtha proper. + + Tritiya detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (2 -> 4) -> return current day + """ + exceptions = { + 2015: date(2015, 5, 20), + } + if year in exceptions: + return exceptions[year] + + # Find Jyeshtha Amavasya + jyeshtha_ama = self._get_amavasya(date(year, 5, 1), zodiac_sign=1) + + if not jyeshtha_ama: + return None + + # Check for another Jyeshtha Amavasya in Adhika Masa + next_jyeshtha_ama = self._get_amavasya(jyeshtha_ama + timedelta(days=1), zodiac_sign=1) + + if next_jyeshtha_ama and next_jyeshtha_ama - jyeshtha_ama <= timedelta(days=31): + jyeshtha_ama = next_jyeshtha_ama + + # Find Tritiya (tithi 3) at sunrise, or skipped case (2 -> 4) + for delta in range(1, 5): + dt = jyeshtha_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 3: + return dt + + if t == 4 and t_prev == 2: + return dt + + return None + + def get_maharishi_valmiki_jayanti(self, year: int) -> date | None: + """ + Maharishi Valmiki Jayanti = Ashwin Purnima. + Tithi = 15 (Purnima) of Shukla Paksha in Ashwin month - sun in + sidereal Virgo (sign 5). + Evaluated at sunrise (Udaya tithi rule). + + Purnima detection: + - Present at sunrise -> return that day + - Skipped between days (14 -> 16) -> return current day + """ + # Find Ashwin Amavasya + ashwin_ama = self._get_amavasya(date(year, 9, 1), zodiac_sign=5) + + if not ashwin_ama: + return None + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16) + for delta in range(12, 17): + dt = ashwin_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + return dt + if t == 16 and t_prev == 14: + return dt + + return None + + def get_mahavir_jayanti(self, year: int) -> date | None: + """ + Mahavir Jayanti = Chaitra Shukla Trayodashi. + Tithi = 13 (Trayodashi) of Chaitra month - sun in sidereal Pisces (sign 11). + Evaluated at sunrise (Udaya tithi rule). + + Trayodashi detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (12 -> 14) -> return previous day + """ + exceptions = { + 2027: date(2027, 4, 18), + } + if year in exceptions: + return exceptions[year] + + # Find Chaitra Amavasya + chaitra_ama = self._get_amavasya(date(year, 3, 1), zodiac_sign=11) + + if not chaitra_ama: + return None + + # Find Trayodashi (tithi 13) at sunrise, or skipped case (12 -> 14) + for delta in range(11, 16): + dt = chaitra_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 13: + return dt + + if t == 14 and t_prev == 12: + return dt - timedelta(days=1) + + return None + + def get_mahesh_navami(self, year: int) -> date | None: + """ + Mahesh Navami = Jyeshtha Shukla Navami. + Tithi = 9 (Navami) of Shukla Paksha in Jyeshtha month - sun in sidereal Taurus (sign 1). + Evaluated at sunrise (Udaya tithi rule). + + Navami detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (8 -> 10) -> return current day + """ + # Find Jyeshtha Amavasya + jyeshtha_ama = self._get_amavasya(date(year, 5, 1), zodiac_sign=1, last=True) + + if not jyeshtha_ama: + return None + + # Find Navami (tithi 9) at sunrise, or skipped case (8 -> 10) + for delta in range(8, 12): + dt = jyeshtha_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 9: + return dt + if t == 10 and t_prev == 8: + return dt + + return None + + def get_matsya_jayanti(self, year: int) -> date | None: + """ + Matsya Jayanti = Chaitra Shukla Tritiya. + Tithi = 3 (Tritiya) of Shukla Paksha in Chaitra month - sun in sidereal Pisces (sign 11). + Evaluated at Aparahna (afternoon rule). + + Tritiya detection: + - Present at Aparahna -> return that day + - Skipped between Aparahnas (2 -> 4) -> return previous day + """ + exceptions = { + 2021: date(2021, 4, 15), + 2033: date(2033, 4, 2), + } + if year in exceptions: + return exceptions[year] + + # Find Chaitra Amavasya + chaitra_ama = self._get_amavasya(date(year, 3, 1), zodiac_sign=11) + + if not chaitra_ama: + return None + + # Find Tritiya (tithi 3) at Aparahna, or skipped case (2 -> 4) + for delta in range(1, 6): + dt = chaitra_ama + timedelta(days=delta) + t = self._tithi(self._aparahna(dt)) + t_prev = self._tithi(self._aparahna(dt - timedelta(days=1))) + + if t == 3: + return dt + if t == 4 and t_prev == 2: + return dt - timedelta(days=1) + + return None + + def get_naag_panchami(self, year: int) -> date | None: + """ + Naag Panchami = Shravan Shukla Panchami. + Tithi = 5 (Panchami) of Shukla Paksha in Shravan month - sun in sidereal Cancer (sign 3). + Evaluated at sunrise (Udaya tithi rule). + + Panchami detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (4 -> 6) -> return current day + """ + # Find Shravan Amavasya + shravan_ama = self._get_amavasya(date(year, 7, 1), zodiac_sign=3, last=True) + + if not shravan_ama: + return None + + # Find Panchami (tithi 5) at sunrise, or skipped case (4 -> 6) + for delta in range(4, 8): + dt = shravan_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 5: + return dt + if t == 6 and t_prev == 4: + return dt + + return None + + def get_naraka_chaturdashi(self, year: int) -> date | None: + """ + Naraka Chaturdashi = Kartik Krishna Chaturdashi. + Tithi = 29 (Chaturdashi) of Krishna Paksha preceding Kartik Amavasya. + Evaluated at Arunodaya (96 minutes before sunrise). + + Chaturdashi detection: + - Present at Arunodaya -> return that day + - Skipped between Arunodayas (28 -> 30) -> return current day + """ + exceptions = { + 2031: date(2031, 11, 13), + } + if year in exceptions: + return exceptions[year] + + # Find Kartik Amavasya (Diwali) + diwali = self.get_diwali(year) + + if not diwali: + return None + + # Find Chaturdashi (tithi 29) at Arunodaya, or skipped case (28 -> 30) + for delta in range(5, -1, -1): + dt = diwali - timedelta(days=delta) + arunodaya = ephem.Date(float(self._sunrise(dt)) - 96 / 1440) + t = self._tithi(arunodaya) + + arunodaya_prev = ephem.Date(float(self._sunrise(dt - timedelta(days=1))) - 96 / 1440) + t_prev = self._tithi(arunodaya_prev) + + if t == 29: + return dt + if t == 30 and t_prev == 28: + return dt + + return None + + def get_parshuram_jayanti(self, year: int) -> date | None: + """ + Parshuram Jayanti = Vaishakha Shukla Tritiya. + Tithi = 3 (Tritiya) of Shukla Paksha in Vaishakha month - sun in sidereal Aries (sign 0). + Evaluated according to the sunset rule. + + Tritiya detection: + - Present at sunset -> return that day + - Begins after sunset -> return next day + - Ends before sunset after being present during the day -> return current day + - Skipped between sunsets (2 -> 4) -> return current day + """ + exceptions = { + 2012: date(2012, 4, 24), + 2015: date(2015, 4, 20), + } + if year in exceptions: + return exceptions[year] + + # Find Vaishakha Amavasya + vaishakha_ama = self._get_amavasya(date(year, 4, 1), zodiac_sign=0) + + if not vaishakha_ama: + return None + + # Find Tritiya (tithi 3) using the sunset observance rule + for delta in range(1, 6): + dt = vaishakha_ama + timedelta(days=delta) + t = self._tithi(self._sunset(dt)) + t_prev = self._tithi(self._sunset(dt - timedelta(days=1))) + t_next = self._tithi(self._sunset(dt + timedelta(days=1))) + + # Tritiya active at sunset + if t == 3: + return dt + + # Tritiya begins after sunset and is active at the following sunset + if t == 2 and t_next == 3: + return dt + timedelta(days=1) + + # Tritiya ends before sunset after spanning the previous sunset + if t == 4 and t_prev == 3: + return dt - timedelta(days=1) + + # Tritiya skipped entirely between sunsets + if t == 4 and t_prev == 2: + return dt + + return None + + def get_pitra_moksh_amavasya(self, year: int) -> date | None: + """ + Pitra Moksh Amavasya = Ashwin Krishna Amavasya. + Tithi = 30 (Amavasya) in Ashwin month - sun in sidereal Virgo (sign 5). + Evaluated according to the Aparahna Vyapini rule for Shraddha rituals. + + Amavasya detection: + - Present during Aparahna Kaal -> return that day + - Present during Aparahna on two consecutive days -> return the first day + """ + exceptions = { + 2005: date(2005, 10, 3), + 2006: date(2006, 9, 22), + } + if year in exceptions: + return exceptions[year] + + # Find Ashwin Amavasya + ashwin_ama = self._get_amavasya(date(year, 9, 1), zodiac_sign=5) + + if not ashwin_ama: + return None + + # Find Amavasya during Aparahna Kaal + for delta in range(-1, 2): + dt = ashwin_ama + timedelta(days=delta) + sunrise = self._sunrise(dt) + sunset = self._sunset(dt) + aparahna_start = ephem.Date(float(sunrise) + (float(sunset) - float(sunrise)) * 3 / 5) + aparahna_end = ephem.Date(float(sunrise) + (float(sunset) - float(sunrise)) * 4 / 5) + + if self._tithi(aparahna_start) == 30 or self._tithi(aparahna_end) == 30: + return dt + + return None + + def get_ram_navami(self, year: int) -> date | None: + """ + Ram Navami = Chaitra Shukla Navami. + Tithi = 9 (Navami) of Shukla Paksha in Chaitra month - sun in sidereal Pisces (sign 11). + Evaluated at Madhyahna (midday rule). + + Navami detection (Madhyahna tithi): + - Present (9) -> track last occurrence + - Skipped (8 -> 10) -> return current day + - Ended (9 -> 10+) -> return last recorded day + """ + exceptions = { + 2005: date(2005, 4, 18), + } + if year in exceptions: + return exceptions[year] + + # Find Chaitra Amavasya + chaitra_ama = self._get_amavasya(date(year, 3, 1), zodiac_sign=11) + + if not chaitra_ama: + return None + + # Find last day Navami (tithi 9) active at Madhyahna, or skipped case (8 -> 10) + last_navami = None + for delta in range(8, 12): + dt = chaitra_ama + timedelta(days=delta) + t = self._tithi(self._madhyahna(dt)) + t_prev = self._tithi(self._madhyahna(dt - timedelta(days=1))) + + if t == 9: + last_navami = dt + elif t == 10 and t_prev == 8: + return dt + elif last_navami: + break + + return last_navami + + def get_rath_yatra(self, year: int) -> date | None: + """ + Rath Yatra = Ashadh Shukla Dwitiya. + Tithi = 2 (Dwitiya) of Shukla Paksha in Ashadh month - sun in sidereal Gemini (sign 2). + Evaluated at sunrise (Udaya tithi rule). + + Dwitiya detection: + - Present at sunrise -> return that day (first occurrence) + - Skipped between days (1 -> 3) -> return current day + """ + # Find Ashadh Amavasya + ashadh_ama = self._get_amavasya(date(year, 6, 1), zodiac_sign=2, last=True) + + if not ashadh_ama: + return None + + # Find Dwitiya (tithi 2) at sunrise, or skipped case (1 -> 3) + for delta in range(1, 5): + dt = ashadh_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 2: + return dt + if t == 3 and t_prev == 1: + return dt + + return None + + def get_shakambhari_purnima(self, year: int) -> date | tuple[date, ...] | None: + """ + Shakambhari Purnima = Pausha Shukla Purnima. + Tithi = 15 (Purnima) of Pausha month - sun in sidereal Sagittarius + (sign 8). Evaluated at sunrise (Udaya tithi rule). + + Purnima detection (sunrise tithi): + - Present at sunrise -> include first day + - Skipped between days (14 -> 16) -> include previous day + + Pausha Amavasya can coincide with the transition from sidereal + Scorpio (sign 7) to Sagittarius (sign 8). In such boundary cases, + the Amavasya may be detected under sign 7 at sunset. + """ + dates = [] + + # Check the Pausha lunar cycles on both sides of the Gregorian year. + for anchor_year in (year - 1, year): + start = date(anchor_year, 12, 1) + + # Find Pausha Amavasya. + pausha_ama = self._get_amavasya(start, zodiac_sign=8) + + # Handle Amavasya occurring at the Scorpio -> Sagittarius boundary. + if not pausha_ama: + pausha_ama = self._get_amavasya(start, zodiac_sign=7) + + if not pausha_ama: + continue + + # Find Purnima (tithi 15) at sunrise, or skipped case (14 -> 16). + for delta in range(12, 18): + dt = pausha_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + festival_date = None + + if t == 15 and t_prev != 15: + festival_date = dt + + elif t == 16 and t_prev == 14: + festival_date = dt - timedelta(days=1) + + if festival_date and festival_date.year == year and festival_date not in dates: + dates.append(festival_date) + + if festival_date: + break + + if not dates: + return None + + dates.sort() + + return dates[0] if len(dates) == 1 else tuple(dates) + + def get_sharad_navratri(self, year: int) -> date | None: + """ + Sharad Navratri = Ashwin Shukla Pratipada. + Tithi = 1 (Pratipada) of Shukla Paksha in Ashwin month - sun in sidereal Virgo (sign 5). + Evaluated at sunrise (Udaya rule). + + Pratipada detection (sunrise tithi): + - Amavasya at sunrise, next=1, day after ≥3 (very short Pratipada) -> return current day + - Present at sunrise (1) -> return current day + - Skipped (30 -> 2) -> return previous day + """ + exceptions = { + 2018: date(2018, 10, 10), + } + if year in exceptions: + return exceptions[year] + + # Find Ashwin Amavasya + ashwin_ama = self._get_amavasya(date(year, 9, 1), zodiac_sign=5, sign_boundary_guard=True) + + if not ashwin_ama: + return None + + # Find Pratipada in the 3 days following Amavasya + for delta in range(3): + dt = ashwin_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + + if t == 30: + t_next = self._tithi(self._sunrise(dt + timedelta(days=1))) + t_next2 = self._tithi(self._sunrise(dt + timedelta(days=2))) + if t_next == 1 and t_next2 >= 3: + return dt + + if t == 1: + return dt + if t == 2 and self._tithi(self._sunrise(dt - timedelta(days=1))) == 30: + return dt - timedelta(days=1) + + return None + + def get_tulsidas_jayanti(self, year: int) -> date | None: + """ + Tulsidas Jayanti = Shravan Shukla Saptami. + Tithi = 7 (Saptami) of Shravan month - sun in sidereal Cancer (sign 3). + Evaluated at sunrise (Udaya tithi rule). + + Saptami detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (6 -> 8) -> return previous day + """ + # Find Shravan Amavasya. + shravan_ama = self._get_amavasya(date(year, 7, 1), zodiac_sign=3) + + if not shravan_ama: + return None + + # Skip Adhika Shravan and use Nija Shravan. + next_shravan_ama = self._get_amavasya(shravan_ama + timedelta(days=25), zodiac_sign=3) + + if next_shravan_ama and next_shravan_ama <= shravan_ama + timedelta(days=40): + shravan_ama = next_shravan_ama + + # Find Shukla Saptami (tithi 7) at sunrise, or skipped case (6 -> 8). + for delta in range(5, 10): + dt = shravan_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 7: + return dt + + if t == 8 and t_prev == 6: + return dt - timedelta(days=1) + + return None + + def get_varalakshmi_vratam(self, year: int) -> date | None: + """ + Varalakshmi Vratam = Last Friday of Shravan Shukla Paksha. + Shravan Purnima = Tithi 15 of Shravan month - sun in sidereal Cancer + (sign 3). Evaluated according to the sunrise rule. + + Purnima detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (14 -> 16) -> return previous day + """ + # Find the last Shravan Amavasya. In Adhik Shravan years, the first + # Amavasya belongs to Adhik Shravan and the last belongs to Nija Shravan. + shravan_ama = None + search_date = date(year, 6, 20) + + while search_date <= date(year, 9, 1): + ama = self._get_amavasya(search_date, zodiac_sign=3) + + if not ama or ama > date(year, 9, 1): + break + + shravan_ama = ama + search_date = ama + timedelta(days=1) + + if not shravan_ama: + return None + + # Find Shravan Purnima at sunrise, or skipped case. + purnima_date = None + for delta in range(12, 18): + dt = shravan_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 15: + purnima_date = dt + break + + if t == 16 and t_prev == 14: + purnima_date = dt - timedelta(days=1) + break + + if not purnima_date: + return None + + # Find the last Friday of Shravan Shukla Paksha. + days_to_friday = (purnima_date.weekday() - 4) % 7 + + return purnima_date - timedelta(days=days_to_friday) + + def get_vikram_samvat_new_year(self, year: int) -> date | None: + """ + Vikram Samvat New Year = Kartik Shukla Pratipada. + Tithi = 1 (Pratipada) of Kartik month - sun in sidereal Libra (sign 6). + Evaluated at sunrise (Udaya tithi rule). + + Pratipada detection (sunrise tithi): + - Present at sunrise -> return that day + - Skipped between days (30 -> 2) -> return previous day + """ + # Find Kartik Amavasya + kartik_ama = self._get_amavasya(date(year, 10, 1), zodiac_sign=6) + + if not kartik_ama: + return None + + # Find Pratipada (tithi 1) at sunrise, or skipped case (30 -> 2). + for delta in range(0, 4): + dt = kartik_ama + timedelta(days=delta) + t = self._tithi(self._sunrise(dt)) + t_prev = self._tithi(self._sunrise(dt - timedelta(days=1))) + + if t == 1: + return dt + + if t == 2 and t_prev == 30: + return dt - timedelta(days=1) + + return None + + +class _Solar(_Astronomy): + """Convert dates from Hindu solar calendar to Gregorian dates.""" + + """ + Sidereal solar zodiac signs (Rashi) + SOLAR_ZODIAC_SIGN_NAMES = [ + "Mesha", # (0°-30°) - Aries (0) + "Vrishabha", # (30°-60°) - Taurus (1) + "Mithuna", # (60°-90°) - Gemini (2) + "Karka", # (90°-120°) - Cancer (3) + "Simha", # (120°-150°) - Leo (4) + "Kanya", # (150°-180°) - Virgo (5) + "Tula", # (180°-210°) - Libra (6) + "Vrishchika", # (210°-240°) - Scorpio (7) + "Dhanu", # (240°-270°) - Sagittarius (8) + "Makara", # (270°-300°) - Capricorn (9) + "Kumbha", # (300°-330°) - Aquarius (10) + "Meena", # (330°-360°) - Pisces (11) + ] + """ + + def get_makar_sankranti(self, year: int) -> date | None: + """ + Makar Sankranti = Sun enters sidereal Capricorn (Makara rashi). + Evaluated at sunset (Pradosh rule). + """ + exceptions = { + 2007: date(2007, 1, 15), + 2023: date(2023, 1, 14), + 2024: date(2024, 1, 14), + } + if year in exceptions: + return exceptions[year] + + for delta in range(6): + dt = date(year, 1, 12) + timedelta(days=delta) + sign = self._sidereal_solar_zodiac_sign(self._sunset(dt)) + sign_prev = self._sidereal_solar_zodiac_sign(self._sunset(dt - timedelta(days=1))) + + if sign == 9 and sign_prev != 9: + return dt + + return None + + def get_vishwakarma_puja(self, year: int) -> date | None: + """ + Vishwakarma Puja = Sun enters sidereal Virgo (Kanya rashi). + Evaluated at sunset. + """ + exceptions = { + 2020: date(2020, 9, 16), + 2024: date(2024, 9, 16), + } + if year in exceptions: + return exceptions[year] + + for delta in range(6): + dt = date(year, 9, 14) + timedelta(days=delta) + sign = self._sidereal_solar_zodiac_sign(self._sunset(dt)) + sign_prev = self._sidereal_solar_zodiac_sign(self._sunset(dt - timedelta(days=1))) + + if sign == 5 and sign_prev != 5: + return dt + + return None + + def get_vishu(self, year: int) -> date | None: + """ + Vishu = Mesha Sankranti. + Sun enters sidereal Aries (Mesha rashi). + Evaluated at sunrise. + + Sankranti detection: + - First sunrise with Sun in Mesha -> return that day + """ + for delta in range(20): + dt = date(year, 4, 5) + timedelta(days=delta) + sign = self._sidereal_solar_zodiac_sign(self._sunrise(dt)) + sign_prev = self._sidereal_solar_zodiac_sign(self._sunrise(dt - timedelta(days=1))) + + if sign == 0 and sign_prev == 11: + return dt + + return None + + +_lunisolar = _Lunisolar() +_solar = _Solar() + +HINDU_LUNISOLAR_HOLIDAYS = ( + ("ANANT_CHATURDASHI", _lunisolar.get_anant_chaturdashi), + ("BASANT_PANCHAMI", _lunisolar.get_basant_panchami), + ("BATHUKAMMA", _lunisolar.get_bathukamma), + ("BONALU", _lunisolar.get_bonalu), + ("BUDDHA_PURNIMA", _lunisolar.get_buddha_purnima), + ("CHAITRA_NAVRATRI", _lunisolar.get_chaitra_navratri), + ("CHHATH_PUJA", _lunisolar.get_chhath_puja), + ("DATTATREYA_JAYANTI", _lunisolar.get_dattatreya_jayanti), + ("DEV_DIWALI", _lunisolar.get_dev_diwali), + ("DIWALI_INDIA", _lunisolar.get_diwali), + ("DUSSEHRA", _lunisolar.get_dussehra), + ("GANESH_CHATURTHI", _lunisolar.get_ganesh_chaturthi), + ("GOVARDHAN_PUJA", _lunisolar.get_govardhan_puja), + ("GURU_NANAK_JAYANTI", _lunisolar.get_guru_nanak_jayanti), + ("GURU_PURNIMA", _lunisolar.get_guru_purnima), + ("GURU_RAVIDAS_JAYANTI", _lunisolar.get_guru_ravidas_jayanti), + ("HANUMAN_JAYANTI", _lunisolar.get_hanuman_jayanti), + ("HARIYALI_AMAVASYA", _lunisolar.get_hariyali_amavasya), + ("HOLI", _lunisolar.get_holi), + ("JANMASHTAMI", _lunisolar.get_janmashtami), + ("KABIR_JAYANTI", _lunisolar.get_kabir_jayanti), + ("KARWA_CHAUTH", _lunisolar.get_karwa_chauth), + ("MAHA_ASHTAMI", _lunisolar.get_maha_ashtami), + ("MAHA_NAVAMI", _lunisolar.get_maha_navami), + ("MAHARANA_PRATAP_JAYANTI", _lunisolar.get_maharana_pratap_jayanti), + ("MAHAVIR_JAYANTI", _lunisolar.get_mahavir_jayanti), + ("MAHARSHI_VALMIKI_JAYANTI", _lunisolar.get_maharishi_valmiki_jayanti), + ("MAHESH_NAVAMI", _lunisolar.get_mahesh_navami), + ("MATSYA_JAYANTI", _lunisolar.get_matsya_jayanti), + ("NAAG_PANCHAMI", _lunisolar.get_naag_panchami), + ("NARAKA_CHATURDASHI", _lunisolar.get_naraka_chaturdashi), + ("PARSHURAM_JAYANTI", _lunisolar.get_parshuram_jayanti), + ("PITRA_MOKSH_AMAVASYA", _lunisolar.get_pitra_moksh_amavasya), + ("MAHA_SHIVARATRI", _lunisolar.get_maha_shivaratri), + ("RAM_NAVAMI", _lunisolar.get_ram_navami), + ("RATH_YATRA", _lunisolar.get_rath_yatra), + ("SHAKAMBHARI_PURNIMA", _lunisolar.get_shakambhari_purnima), + ("SHARAD_NAVRATRI", _lunisolar.get_sharad_navratri), + ("TULSIDAS_JAYANTI", _lunisolar.get_tulsidas_jayanti), + ("VARALAKSHMI_VRATAM", _lunisolar.get_varalakshmi_vratam), + ("VIKRAM_SAMVAT_NEW_YEAR", _lunisolar.get_vikram_samvat_new_year), +) + +HINDU_SOLAR_HOLIDAYS = ( + ("MAKAR_SANKRANTI", _solar.get_makar_sankranti), + ("VISHWAKARMA_PUJA", _solar.get_vishwakarma_puja), + ("VISHU", _solar.get_vishu), +) + + +def generate_data() -> None: + years = range(2001, 2101) + + calendars = ( + ("hindu_lunisolar", "_HinduLunisolar", HINDU_LUNISOLAR_HOLIDAYS), + ("hindu_solar", "_HinduSolar", HINDU_SOLAR_HOLIDAYS), + ) + + for cal_name, class_name, holidays in calendars: + dates: dict[str, dict[int, date | list[date]]] = defaultdict(dict) + + for hol_name, hol_func in holidays: + for year in years: + dt = hol_func(year) + + if dt: + dates[hol_name][year] = list(dt) if isinstance(dt, tuple) else dt + + CalendarGenerator(cal_name, class_name).generate(dates) + + +if __name__ == "__main__": + generate_data() diff --git a/uv.lock b/uv.lock index d66070ab46..59a32ccd5f 100644 --- a/uv.lock +++ b/uv.lock @@ -501,6 +501,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "ephem" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/f0/a38e882d3c73bb8bcc37a0a27c9a7b8fceb7906312006fb03c01d0a098b3/ephem-4.2.1.tar.gz", hash = "sha256:920cb30369c79fde1088c2060d555ea5f8a50fdc80a9265832fd5bf195cf147f", size = 1261529, upload-time = "2026-02-28T09:48:25.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/87/96732dd3a1446be66fddae1b64fe219d8f9db8e8ff112fb760bf18bcda3f/ephem-4.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d755cd3b3440dc862606997c5860e9e92c5bbede1cb16de517189868cd0431b", size = 1431809, upload-time = "2026-02-28T09:45:56.447Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/5575e7e897cf08fa1eadb5a085af7575df5ca813792a6eff17e5ebd96b10/ephem-4.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c63829dc50343f98d67c5e8772efce16d1793a5a5dfd908bc7d59482a4c3689", size = 1432830, upload-time = "2026-02-28T09:45:57.645Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/9b62f32ae21db542e800edb793179c96e0814cd0035a1b37fba54e5fe499/ephem-4.2.1-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:82946e302c9cfbf304ef39b48de9ac2957a903061591f2c104d43813566c7595", size = 1764749, upload-time = "2026-02-28T09:45:58.99Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d9/6ddd29e36b0bcf1ecc38062ea8e751f9f8cf73fa40abda3b963d912b21b8/ephem-4.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:480a0b779ab6e8349aed3b0f8b3a4e0977d10c141dff1ee449efa1cffe504516", size = 1785042, upload-time = "2026-02-28T09:46:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/5b/51/bef3d0f42670e79b9fabe90ec5860a247c7f6f80dbb40b4358449816c76a/ephem-4.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9fa0469ef7ba9a44f6cf98a84f94b6ba196a31a1dd586cb56e3b0fd491eb1222", size = 1829683, upload-time = "2026-02-28T09:46:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/19/76/9270bc08174d2c11237d2fae9bd17f62628055d0b389355f5d14a33c93c6/ephem-4.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41e2f841e466df9310bd36a40e2870aa8fa11c3095bbd9046dceee9043642688", size = 1780012, upload-time = "2026-02-28T09:46:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/62/5a/a1bf10700b5bf2bbcc19c5814e8afaf41d538d35ba648b30664545b1e435/ephem-4.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:75e705b6fa2357ddca2f8c9822d7dd5151ea57abf824686069823995f601c8cb", size = 1782971, upload-time = "2026-02-28T09:46:04.993Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/3fc3c945afe9c1d29d6a03fef21dd6702dac3190e5426e2de7ea694e1820/ephem-4.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d2a691c9fcf91d1c89d79543f497db0961e40bb9bdaef67d29b3002f18fe48f1", size = 1794757, upload-time = "2026-02-28T09:46:06.543Z" }, + { url = "https://files.pythonhosted.org/packages/6d/79/20eb93e41b67e0ba17a61d14faa09db83734dfc76580d00cab63a612a2d1/ephem-4.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:de6ba146da398f84e32f8e43ed77d3b58a8482fbc91065f02eb35e30452ad602", size = 1812917, upload-time = "2026-02-28T09:46:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/b5/77/73d4f057f01d0335c8ddfd94ac81c4a919c8a0254bd64ae3c1b4d5a416c0/ephem-4.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:314d12bd7c0bea470592a93acb39c2d3e0ce655f20a64504551868d0f54a71f6", size = 1779796, upload-time = "2026-02-28T09:46:09.3Z" }, + { url = "https://files.pythonhosted.org/packages/a5/81/4722408f857a1ff51372f673009c7804de922d81ef4fc810ad3add1ef152/ephem-4.2.1-cp310-cp310-win32.whl", hash = "sha256:f731e789a707eff92c23aa766dbab879ca7db014ecb552685211dfd44a05c173", size = 1399611, upload-time = "2026-02-28T09:46:10.463Z" }, + { url = "https://files.pythonhosted.org/packages/c1/46/48a15d321f3610b909d68543f77dc0fa9fb9566e2e8b9ccacacf647439ec/ephem-4.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:5f6db070bbe97e5a20bce35784187bc8b3e83a8b503e5dab603734f742873a4d", size = 1414791, upload-time = "2026-02-28T09:46:11.954Z" }, + { url = "https://files.pythonhosted.org/packages/0d/10/cb34f1872b64b4e1a72e26492013e6c50dd8568c08c9061f1324e1a1f31d/ephem-4.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2712e8b855506a47acb9dfb5b57a4dff2336ec398a336b83b75a6357a8a41d90", size = 1431803, upload-time = "2026-02-28T09:46:13.487Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e4/86a44b57e002ac2e51c6264581860ea86ec98a331cbed30fe4ea1edbf22a/ephem-4.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c2c173a9757bd25393b7d32a0865fbe85b337e0f83d26244a91b38da423e117", size = 1432830, upload-time = "2026-02-28T09:46:15.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/49/a7499216d76bb4ce154d4f4c38b983ed7f45e9d53793b80ec44364ab4850/ephem-4.2.1-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e75c418c0f729a62f0eb3036cf502c038be16ac896e721021e4caebbe6b65392", size = 1765322, upload-time = "2026-02-28T09:46:16.552Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/2f13493d5fe82931e9c2a4bb500abfb9068cbdf121af0250734b301fff02/ephem-4.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bded885b77693998500bedb2c67e89b5947f53505eed6567c9aafad9d5434139", size = 1785675, upload-time = "2026-02-28T09:46:19.394Z" }, + { url = "https://files.pythonhosted.org/packages/84/48/93ffeac4aad6225a40e9a7813a877971759b2978d0b3e0400e3b31733bd1/ephem-4.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b122062a85b5fdb862a9e2b7eaf5fe7206356fe72b49041354398494bf8adde", size = 1830089, upload-time = "2026-02-28T09:46:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/7f/50/32091a3a9e65c869f16b1da335273e01dbef2914d793fea340aa82c2c07a/ephem-4.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b89e5dc7f10ac5f3042d2ebd3de1f33f315d9c1919481ebad308bf5c0915eef3", size = 1780555, upload-time = "2026-02-28T09:46:22.332Z" }, + { url = "https://files.pythonhosted.org/packages/e8/79/aaffd160408f77273ffb4a713f67df96f7a7673e868edd5a0b53cae79e6c/ephem-4.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bbc30121a967ea101f26d2367fe1d323e18872ddc7e1ddf8125c379554005ec7", size = 1783673, upload-time = "2026-02-28T09:46:23.561Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/a583989a43ed4934422bcbeb26450e5a99da2afe25ff673efb6f02bcf834/ephem-4.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2b9109cb292f3eaadbb9a47ccca136ea5a4717bb8c4374597b4119ce7a3f593e", size = 1795136, upload-time = "2026-02-28T09:46:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d29213513df9e1029520eeb63e10d7f3f25f9263c3264f64e8851391dd42/ephem-4.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c65beca42cb406875f8b2df0294544615320654b69494485f16fb986ba223cb9", size = 1813529, upload-time = "2026-02-28T09:46:26.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/aa/aeab9d02ae1d5e81d4bce50762666653284478814dc8cea985b78f8c8916/ephem-4.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff2d504ccc229b633443fe6fe7e3a806cf7eabac5a1d3d68e25b753a4b9b8217", size = 1780302, upload-time = "2026-02-28T09:46:27.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a2/2ae6f5f70194d8c6489990bef50116349d277978023db127f1d946aa1c46/ephem-4.2.1-cp311-cp311-win32.whl", hash = "sha256:af5b8cb5509f61f874c8429da0b5e4a420be60b99cd5ceb21d6938d948ccb718", size = 1399616, upload-time = "2026-02-28T09:46:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/14/0f64f5404916ff8d8dcf6452679431bd1daa2ea4a518a3a6f4e1bc1fa16a/ephem-4.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f298da8ce31c541ed0bf1c680d0b53015859bc3a8a99551b880b510bc8cac6", size = 1414794, upload-time = "2026-02-28T09:46:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/b8/59/5849e612e6460c82b36396d91fd63d0126f72bbe9fe2f6a990bd1d03068f/ephem-4.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de9b0ed3836f4b2765e0556058e0dcc595c8226f7aa9d0f04f09b5775b792525", size = 1431686, upload-time = "2026-02-28T09:46:32.396Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0f/345f5801f90d1e0888bdaeb0ad23559cd1bcb50841247c90aa5edf243ed4/ephem-4.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0017a0ff0eac2632943d37b9f12cfea65062e9dc0b3476d17a11319409f0b82f", size = 1433118, upload-time = "2026-02-28T09:46:33.779Z" }, + { url = "https://files.pythonhosted.org/packages/5b/37/f5d55fe605493d4509b35805c0a4b89ffb687fbecd02d58adea398e92344/ephem-4.2.1-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f3ee0fdb7a0c19d0d4d6043f3f52f216fcf38619783c4ffad285f8f71ec26cd9", size = 1768826, upload-time = "2026-02-28T09:46:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/79/b7/ce41706b23a572f1c9658262ec164eea7e74a2519b866dddf656332f7d88/ephem-4.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17c09bc88fca62284bd5f055dc3fcad8a5fc38de35f59f451ade82761512a45f", size = 1788922, upload-time = "2026-02-28T09:46:36.675Z" }, + { url = "https://files.pythonhosted.org/packages/9f/44/a07a6247b5b75e9177e92a1e83076ba9bea62f3d54d0ff25d1597707076c/ephem-4.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd8ec19883545c7ff97fab95035862c37827723eae7135e6d78c32d3ac1b413b", size = 1834037, upload-time = "2026-02-28T09:46:38.055Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b4/116a78a309c975c05f69e6d26208d8e607fb84de8f0fa0090306d3cc63f0/ephem-4.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f16ed34b60056eeae3d8afc78ec528981d5a90977e1bc734b9308279eb016791", size = 1784685, upload-time = "2026-02-28T09:46:39.426Z" }, + { url = "https://files.pythonhosted.org/packages/03/be/65d2c768b66b18f9a7f255a3c73963e80001bcb3218af40fe785bc802c31/ephem-4.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5df38b498eaa779385f2422263af0f9688120dee4daddb47f58a03ffd3eaac56", size = 1787128, upload-time = "2026-02-28T09:46:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/64/2d/99d409df0b750036a150a6a7e962811b38c0b02e7f77f30b1aa73bda73ff/ephem-4.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bd1927af82de44f0c6d6690c3d6ec3a57a619ecdba20706480ffd2b65ee59bec", size = 1798850, upload-time = "2026-02-28T09:46:41.962Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5e/5cf9d592ae14d2a46a0b8429381e0ea1436e62d044ce1031ffe25f4d79b7/ephem-4.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:05a216cbb739de6c412f447da32f5f613d15c582424dd07d5341cda798cb80f1", size = 1817530, upload-time = "2026-02-28T09:46:44.005Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/bb285a1a14abdf630939809538dfe92531881492b23e1d108a2ff2ba464e/ephem-4.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbda62115c9d1c2d967b6b3b80d4ab3b2c7662c2dfd587ba50fc55a6c48aa80", size = 1784631, upload-time = "2026-02-28T09:46:45.222Z" }, + { url = "https://files.pythonhosted.org/packages/40/4c/5297a1cd52d4b1ab7d96e941ebec2086ffef75e42d4df663900c31691994/ephem-4.2.1-cp312-cp312-win32.whl", hash = "sha256:979b2ef09eb29674ca2742124c1ffc231dcbbcb0fcc7f312b5e70cb7e43a62fc", size = 1399811, upload-time = "2026-02-28T09:46:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/06/b4/f210bbb64fbc59e4d2e72433e767396a3471fde26f49fd56ac6930f36532/ephem-4.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c35502ba6c87cf1d3ff7ecd3690fadeea39583ef783d7d365037b385d3d72eb0", size = 1414939, upload-time = "2026-02-28T09:46:48.078Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8a/2af7194252902b8f9bd6f282051864d86311b90e846a9f9a6c33798d60a9/ephem-4.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19ad36d7a5bf63cd424576ff392d7bd9bf579d903b14ecd98173d577a51051e1", size = 1431651, upload-time = "2026-02-28T09:46:49.335Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3f/d28fb26afacb07c81268a5f7b6a1c2b4a14d0304899c20502205dfccdeaf/ephem-4.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:458637fd487cdc897a86912089086161f042c3525a268245490565255bcf46a0", size = 1433094, upload-time = "2026-02-28T09:46:50.736Z" }, + { url = "https://files.pythonhosted.org/packages/43/b9/008cbb78827b145b7afaa152af6991ba7fc5baa99cf16cf64ec9f0fef9ce/ephem-4.2.1-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:b99bdfa5372d83f43c052c845c05260e9355824dc0a88bbb0b9b4bdd438305fc", size = 1768840, upload-time = "2026-02-28T09:46:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b2/2e6674089d4eb0e4cc4bde6e30d841ff9bcd2d513deebb4ae41b83eaef84/ephem-4.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ae26bc5abc4073edf6898e5969f3e7b77c47d3c88521177cb3df14fe8963259", size = 1788881, upload-time = "2026-02-28T09:46:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/c04ddeea27c093146d064c994bd795ce5244533b839ef7ccfe35850fb289/ephem-4.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078894d72e38cfaee8d00959d6720506af61c0fba5ef42f9e2ea1528296a4320", size = 1834059, upload-time = "2026-02-28T09:46:56.083Z" }, + { url = "https://files.pythonhosted.org/packages/53/1c/c7149f3be5e9852316907621e6d6e2f8833b2044c12f041157495eb74358/ephem-4.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b37ef161ca287f025c7c299b612fbb7e83530840c938d06a61fcd7889a66db5", size = 1784679, upload-time = "2026-02-28T09:46:57.908Z" }, + { url = "https://files.pythonhosted.org/packages/eb/54/3f8575295de6c07b151d73dfa4d7ed0dd3efe6a02b7b633fbd74381e13d2/ephem-4.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e9934d309fb3c6d79373259b2a9fc71874a512b90d61523e334720805b6fa5c", size = 1787191, upload-time = "2026-02-28T09:46:59.71Z" }, + { url = "https://files.pythonhosted.org/packages/0d/72/b961fa4b1efb39ac0021e1e31fb3c0a6c5fad45d7fde4908748252e2ac17/ephem-4.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5530ca3c85a22d31838844427b96abcbe96da6048b6e83a675a5bcc23e5af43c", size = 1798895, upload-time = "2026-02-28T09:47:01.067Z" }, + { url = "https://files.pythonhosted.org/packages/74/ce/93ad503431136611187a60c986441510cd69908add782003526e0d7bb24b/ephem-4.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:61e38e2affe676fc9f9a8fbd4f575f05ca7db4f87a6e5fc589a09c14fe68850e", size = 1817637, upload-time = "2026-02-28T09:47:02.712Z" }, + { url = "https://files.pythonhosted.org/packages/59/ff/fbcd7a7795741e5770c73b39dd1318215b357f6e8d503233ca3b3fb10437/ephem-4.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:46427d35a2fbf4a95fcf6de9d36dc0a29e49b96a63654856285f0262c4082fc7", size = 1784648, upload-time = "2026-02-28T09:47:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c9/340dd18a288ecdf079ae15711a84d8fb82bad63dc7f1e985b88aeaeea81c/ephem-4.2.1-cp313-cp313-win32.whl", hash = "sha256:a075014299467d29598019fb11972a8f3f476d28962987231b24a234ea8764f2", size = 1399821, upload-time = "2026-02-28T09:47:06.162Z" }, + { url = "https://files.pythonhosted.org/packages/57/70/323fd7145e7a9abb4878c1abb5822b9578db3fa4d8d952806941d7c7910d/ephem-4.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:f22a797ab3658f5f1ae56dd32627e9ef4766aa3e9dbddc817cbb2741893a8a28", size = 1414942, upload-time = "2026-02-28T09:47:07.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/42/0a94728aaf67b9bb7ee2f0f300380ebaea3362f188c1d9956c0d88ada6ec/ephem-4.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6aa46747d0866afcaf64c25855546ecbeccb58456a93b368c963a8e18e414794", size = 1431765, upload-time = "2026-02-28T09:47:09.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/45/e3528adb5e496b4f2c6853313b4bb65f3ea665b41cd4553847ece39040f1/ephem-4.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8339dcc7187c99b169d3b0f650b9e1f1047f6d7687bda025acbdeb049ae3e9a2", size = 1433099, upload-time = "2026-02-28T09:47:11.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/aa/e8b86b27357eafbbdc15ab315014c15651c9009d504d73b9e1fab7ac7062/ephem-4.2.1-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:560a417a4252f4c77a8ebc325a081c684f24c2287f82579ee4eb62d382dcfa89", size = 1769064, upload-time = "2026-02-28T09:47:13.632Z" }, + { url = "https://files.pythonhosted.org/packages/38/28/971ada8acdf52afafb1866cc474dba1cc3deb631f7092e7dc10faba88198/ephem-4.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ce6b0b076b18e9b7a7a9ff5f37573455849079ec232eae0419c172304e7d2aa", size = 1788690, upload-time = "2026-02-28T09:47:15.182Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b3/75194b704da47e649a449d7797ce78f18b3147dfee761fa7dea83eb23fd9/ephem-4.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ec91aa7735a10bfd8a4f79afb39567500483a1b5812a3ba675fb10abb5aa95d1", size = 1833795, upload-time = "2026-02-28T09:47:16.993Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/578499dc1383ddec58a79be525e54d47a23210c8147a65610ee7cf477406/ephem-4.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3610fd501549fe8e29417ecd9fecdbe19e1bc416bd76ee24e40ae81ce3049760", size = 1784463, upload-time = "2026-02-28T09:47:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/8c/21/c76b805d70df3e45cf4795ec19bdb91b0fe42d473b56ea8c80f7706af1f6/ephem-4.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:27b8d6893cc47765ab55b2eb4f4bad97b8ffa844ff6b67b0dcad44e48d0b6e77", size = 1786832, upload-time = "2026-02-28T09:47:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/79e7eb46c1bda35187a4cacc767f3d6707247a4afc759981c3c69ed6af6b/ephem-4.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e29c7aabcc32af070d99fd7a2268e7c854d7b68df3414133b5581eb9996939ce", size = 1799002, upload-time = "2026-02-28T09:47:21.594Z" }, + { url = "https://files.pythonhosted.org/packages/61/ef/6ab516d889986a7c16731f9b7a89a9a689ca9a3196dc544d187f2f82f9da/ephem-4.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b831567c5db7b101e9e647cfac38da458f48b4c66bee949cff11a875f260d153", size = 1817236, upload-time = "2026-02-28T09:47:22.97Z" }, + { url = "https://files.pythonhosted.org/packages/4c/12/9c4d2014ddad6a9c4e3a7cc43ed9f9585d60a04475dc47b8822b78fbb3dd/ephem-4.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7359e573798357318738f628660946e4cee89f39e84940264e428504d541921b", size = 1784500, upload-time = "2026-02-28T09:47:24.319Z" }, + { url = "https://files.pythonhosted.org/packages/47/b5/59b5554ba2f8a850a65e3beb52cf290b4616e2e94827261b3a3b826e1ac4/ephem-4.2.1-cp314-cp314-win32.whl", hash = "sha256:c0bc8c928b7ef10167b7a2f2f20c7ee079c3b988b9a3a943bda123949acef79f", size = 1406790, upload-time = "2026-02-28T09:47:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a1/a40f57eae2589933943369d0aa4de024fbba18f5689c06c5f012b59627de/ephem-4.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:936d7a8745fbb18869e238d541f027cd7fc086e3fdfb826cb76fcfb4bed3e35a", size = 1423041, upload-time = "2026-02-28T09:47:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/84/bb/55932750261f35d84c944941dff761112eb9582b608b314fd8c4b87e89a9/ephem-4.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1e5806b5a633fb25480486d5419604e82a6932f9f9e6855f1e07da852b8de570", size = 1432847, upload-time = "2026-02-28T09:47:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/d1/8e/c6bbb724f1a51ec79dc9c167dfde27246095abecdf45057fc6de50822e94/ephem-4.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ad67a86ffeeda5173d88b04b9477b5ca3e78331acc168a084d18779452c0f7a", size = 1433840, upload-time = "2026-02-28T09:47:30.009Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2e/84b07818630298656dbae0c95c0e99b89ff98d6a87daf9819a5c5aee191b/ephem-4.2.1-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:2b8792e60114a07253160e81b63f28b3c021f4a5dfbee91a6db526965bdc039f", size = 1782457, upload-time = "2026-02-28T09:47:31.827Z" }, + { url = "https://files.pythonhosted.org/packages/be/fc/357df051cdc848b482c823f18dc94632d40717c64fc0068d79b91cf12f75/ephem-4.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7812f16600ecec6d7b21194a38948ea3a0c406332481375e735924a3f82f5e29", size = 1804033, upload-time = "2026-02-28T09:47:33.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/cc/4348e26e0ba41a5e5e6e4ecc1fca81ceaccc656c9ad3d4251c301bf34d78/ephem-4.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e020cb6976ff3badb303bc55026366fa3886280f15f6d4d46c52fa36356fe16b", size = 1847470, upload-time = "2026-02-28T09:47:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9c/77f0007679d0de12372db61d863bee604441b4827b27dbcbed07f48a815c/ephem-4.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:788e61fa779c54e6dd745f57adaeeff14eda2b8dd737ef578af6ac8415d31a48", size = 1796560, upload-time = "2026-02-28T09:47:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/68/6f/30751b504be2c55b9a2a77f678003f0e065554d1f2f75f3ec85162f2ccd0/ephem-4.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e8c6598443375f119c9f7c586f3198c6ac60a38bac3fbc08781c55c8e102cbba", size = 1800619, upload-time = "2026-02-28T09:47:37.755Z" }, + { url = "https://files.pythonhosted.org/packages/fa/79/49981b3ad843f2180a17ea6f89874ff5ec4d26b6d6fad754be4cbaf97d13/ephem-4.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d62cea09046153052cc15c85938fadf7648a7c8b1133d729b76f20d4cabcfefc", size = 1811800, upload-time = "2026-02-28T09:47:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/235acf25dfa74e041a4d95b3fbe6835ddd152d6479ebb762da31818bee09/ephem-4.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bfdcc68b2a5ac84305d792ca8ffac23bed0b5be21edbf45b33dd00423f7a6fdb", size = 1829542, upload-time = "2026-02-28T09:47:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/0e/70/429ee91d941b4969571fca658f3350eb5e217f04e4aa9bc295b77613ec3b/ephem-4.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bc51d5c926b65ec15742042de79f6181a979809a6f9da1bd8d6abfb23415b7", size = 1795831, upload-time = "2026-02-28T09:47:42.703Z" }, + { url = "https://files.pythonhosted.org/packages/9b/92/cc35c78c00894e64d312960507ee84c9fa76919ffca28a7f92ffa59bda20/ephem-4.2.1-cp314-cp314t-win32.whl", hash = "sha256:ce86665355d88e4e7ccc3b9e9c74905a2c6ad51c27433e5c98371a7ea38ad502", size = 1407887, upload-time = "2026-02-28T09:47:44.374Z" }, + { url = "https://files.pythonhosted.org/packages/da/c8/b0f1788a6a87a0a18e799b73486329f33f0ae4c152d62bca46476e76fc35/ephem-4.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c4e6e15dc6ccc22504e27aea58a06f8308fd7eb007947d16dfcea6a3aec28b4d", size = 1424698, upload-time = "2026-02-28T09:47:46.38Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -612,6 +692,7 @@ ci = [ { name = "uv" }, ] dev = [ + { name = "ephem" }, { name = "gitpython" }, { name = "hijridate" }, { name = "lingva" }, @@ -654,6 +735,7 @@ ci = [ { name = "uv", specifier = "==0.12.2" }, ] dev = [ + { name = "ephem", specifier = ">=4.2.1,<5" }, { name = "gitpython", specifier = ">=3.1.50,<4" }, { name = "hijridate", specifier = ">=2.5,<3" }, { name = "lingva", specifier = ">=5.0.6,<6" },