-
-
Notifications
You must be signed in to change notification settings - Fork 705
Add astronomical Sinhala calendar implementation #3672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,221 @@ | ||||||
| # 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 <dr.prodigy.github@gmail.com> (c) 2017-2023 | ||||||
| # ryanss <ryanssdev@icloud.com> (c) 2014-2017 | ||||||
| # Website: https://github.com/vacanza/holidays | ||||||
| # License: MIT (see LICENSE file) | ||||||
|
|
||||||
| """ | ||||||
| Astronomical calculations for the Sinhala lunisolar calendar. | ||||||
|
|
||||||
| This module computes astronomical new moons, full moons, sidereal solar | ||||||
| longitude using the Lahiri ayanamsha, Sinhala lunar month assignment, | ||||||
| and generates astronomical Poya observations. | ||||||
| """ | ||||||
|
|
||||||
| from datetime import timedelta, timezone | ||||||
|
|
||||||
| from skyfield import almanac | ||||||
| from skyfield.api import Star, load, wgs84 | ||||||
| from skyfield.data import hipparcos | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Prompt for AI agents |
||||||
|
|
||||||
| ts = load.timescale() | ||||||
| eph = load("de440s.bsp") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The module eagerly loads astronomical datasets ( Prompt for AI agents |
||||||
|
|
||||||
| earth = eph["earth"] | ||||||
| sun = eph["sun"] | ||||||
| moon = eph["moon"] | ||||||
|
|
||||||
| with load.open(hipparcos.URL) as f: | ||||||
| stars = hipparcos.load_dataframe(f) | ||||||
|
Comment on lines
+27
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Import-time downloads and file writes will bite users in offline/CI environments. Loading the ephemeris and Hipparcos catalog at module import triggers network downloads (~32 MB for ♻️ Sketch: lazy initialization_eph = None
def _get_eph():
global _eph
if _eph is None:
_eph = load("de440s.bsp")
return _eph🤖 Prompt for AI Agents |
||||||
|
|
||||||
| SPICA = Star.from_dataframe(stars.loc[65474]) | ||||||
|
|
||||||
| SL_TIMEZONE = timezone(timedelta(hours=5, minutes=30)) | ||||||
|
|
||||||
| NEW_MOON_SEARCH_DAYS = 40 | ||||||
| FULL_MOON_OFFSET_DAYS = 20 | ||||||
|
|
||||||
| UJJAIN = wgs84.latlon( | ||||||
| latitude_degrees=23.15, | ||||||
| longitude_degrees=75.7683, | ||||||
| ) | ||||||
|
|
||||||
| SINHALA_MONTHS = { | ||||||
| 0: "Vesak", # Aries (Mesha) | ||||||
| 1: "Poson", # Taurus (Vrishabha) | ||||||
| 2: "Esala", # Gemini (Mithuna) | ||||||
| 3: "Nikini", # Cancer (Karka) | ||||||
| 4: "Binara", # Leo (Simha) | ||||||
| 5: "Vap", # Virgo (Kanya) | ||||||
| 6: "Ill", # Libra (Tula) | ||||||
| 7: "Uduvap", # Scorpio (Vrischika) | ||||||
| 8: "Duruthu", # Sagittarius (Dhanu) | ||||||
| 9: "Navam", # Capricorn (Makara) | ||||||
| 10: "Madin", # Aquarius (Kumbha) | ||||||
| 11: "Bak", # Pisces (Mina) | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| def lahiri_ayanamsha(ts_time): | ||||||
|
|
||||||
| _, longitude, _ = earth.at(ts_time).observe(SPICA).apparent().ecliptic_latlon("date") | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is Spica here? |
||||||
| return (longitude.degrees - 180.0) % 360.0 | ||||||
|
|
||||||
|
|
||||||
| def sidereal_longitude(body, ts_time): | ||||||
|
|
||||||
| _, tropical_longitude, _ = earth.at(ts_time).observe(body).apparent().ecliptic_latlon("date") | ||||||
| return (tropical_longitude.degrees - lahiri_ayanamsha(ts_time)) % 360 | ||||||
|
|
||||||
|
|
||||||
| def sidereal_sign(ts_time): | ||||||
|
|
||||||
| longitude = sidereal_longitude(sun, ts_time) | ||||||
| return int(longitude // 30), longitude | ||||||
|
|
||||||
|
|
||||||
| def lunar_phase(ts_time): | ||||||
|
|
||||||
| sun_long = sidereal_longitude(sun, ts_time) | ||||||
| moon_long = sidereal_longitude(moon, ts_time) | ||||||
| return ( | ||||||
| (moon_long - sun_long) % 360, | ||||||
| sun_long, | ||||||
| moon_long, | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def tithi(phase): | ||||||
|
|
||||||
| return int(phase // 12) + 1 | ||||||
|
|
||||||
|
|
||||||
| def find_surrounding_new_moons(sunrise): | ||||||
|
|
||||||
| search_start = ts.tt_jd(sunrise.tt - NEW_MOON_SEARCH_DAYS) | ||||||
| search_end = ts.tt_jd(sunrise.tt + NEW_MOON_SEARCH_DAYS) | ||||||
|
|
||||||
| times, phases = almanac.find_discrete( | ||||||
| search_start, | ||||||
| search_end, | ||||||
| almanac.moon_phases(eph), | ||||||
| ) | ||||||
|
|
||||||
| prev_new_moon = None | ||||||
| next_new_moon = None | ||||||
|
|
||||||
| for t, moon_phase in zip(times, phases): | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add explicit Flagged here and also at lines 139 and 192. ♻️ Proposed fix- for t, moon_phase in zip(times, phases):
+ for t, moon_phase in zip(times, phases, strict=True):📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.15.20)[warning] 113-113: Add explicit value for parameter (B905) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||
| if moon_phase != 0: | ||||||
| continue | ||||||
|
|
||||||
| if t.tt <= sunrise.tt: | ||||||
| prev_new_moon = t | ||||||
| continue | ||||||
|
|
||||||
| if next_new_moon is None: | ||||||
| next_new_moon = t | ||||||
|
|
||||||
| return prev_new_moon, next_new_moon | ||||||
|
|
||||||
|
|
||||||
| def get_sunrise(target_date): | ||||||
|
|
||||||
| start = ts.utc( | ||||||
| target_date.year, | ||||||
| target_date.month, | ||||||
| target_date.day, | ||||||
| ) | ||||||
| end = ts.tt_jd(start.tt + 1) | ||||||
|
|
||||||
| sunrise_fn = almanac.sunrise_sunset(eph, UJJAIN) | ||||||
| times, events = almanac.find_discrete(start, end, sunrise_fn) | ||||||
|
|
||||||
| for t, event in zip(times, events): | ||||||
| if event == 1: | ||||||
| return t | ||||||
|
|
||||||
| raise RuntimeError(f"No sunrise found for {target_date}") | ||||||
|
|
||||||
|
|
||||||
| def get_month_info(target_date): | ||||||
|
|
||||||
| sunrise = get_sunrise(target_date) | ||||||
| prev_new_moon, next_new_moon = find_surrounding_new_moons(sunrise) | ||||||
|
|
||||||
| prev_sign, _ = sidereal_sign(prev_new_moon) | ||||||
| next_sign, _ = sidereal_sign(next_new_moon) | ||||||
| phase, _, _ = lunar_phase(sunrise) | ||||||
|
|
||||||
| return { | ||||||
| "month": SINHALA_MONTHS[prev_sign], | ||||||
| "leap_month": prev_sign == next_sign, | ||||||
| "tithi": tithi(phase), | ||||||
| "previous_sign": prev_sign, | ||||||
| "next_sign": next_sign, | ||||||
| "previous_new_moon": prev_new_moon.utc_datetime(), | ||||||
| "next_new_moon": next_new_moon.utc_datetime(), | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| def get_fullmoon_month(target_date): | ||||||
|
|
||||||
| month_info = get_month_info(target_date) | ||||||
|
|
||||||
| if month_info["tithi"] < 16: | ||||||
| return month_info["month"] | ||||||
|
|
||||||
| future_date = target_date + timedelta(days=FULL_MOON_OFFSET_DAYS) | ||||||
| future_month_info = get_month_info(future_date) | ||||||
|
|
||||||
| return future_month_info["month"] | ||||||
|
|
||||||
|
|
||||||
| def find_full_moons(year): | ||||||
|
|
||||||
| start = ts.utc(year, 1, 1) | ||||||
| end = ts.utc(year + 1, 1, 1) | ||||||
|
|
||||||
| times, phases = almanac.find_discrete( | ||||||
| start, | ||||||
| end, | ||||||
| almanac.moon_phases(eph), | ||||||
| ) | ||||||
|
|
||||||
| full_moons = [] | ||||||
|
|
||||||
| for t, phase in zip(times, phases): | ||||||
| if phase == 2: | ||||||
| full_moons.append(t) | ||||||
|
|
||||||
| return full_moons | ||||||
|
|
||||||
|
|
||||||
| def generate_poya_dates(year): | ||||||
| """ | ||||||
| Generate astronomical Poya observations for a Gregorian year. | ||||||
|
|
||||||
| The returned civil date is the Sri Lanka local date containing the | ||||||
| astronomical full moon. Sinhala month assignment is determined using | ||||||
| ``get_fullmoon_month()``. | ||||||
| """ | ||||||
|
|
||||||
| poya_dates = [] | ||||||
|
|
||||||
| for full_moon in find_full_moons(year): | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A full moon occurring near the end of a UTC year (e.g., Dec 31 08:48 UTC) converts to the next calendar day in Sri Lanka (UTC+5:30), producing a local date of Jan 1 of the following year. However, since Prompt for AI agents |
||||||
| local_datetime = full_moon.utc_datetime().astimezone(SL_TIMEZONE) | ||||||
|
|
||||||
| poya_dates.append( | ||||||
| { | ||||||
| "date": local_datetime.date(), | ||||||
| "month": get_fullmoon_month(local_datetime.date()), | ||||||
| "full_moon": local_datetime, | ||||||
| } | ||||||
| ) | ||||||
|
Comment on lines
+179
to
+219
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map the file and locate the relevant symbols.
git ls-files holidays/calendars/sinhala_astronomy.py
ast-grep outline holidays/calendars/sinhala_astronomy.py --view expanded
# Show the relevant lines with numbering.
sed -n '1,280p' holidays/calendars/sinhala_astronomy.py | cat -nRepository: vacanza/holidays Length of output: 8182 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Run a small read-only probe to see whether a full moon near a UTC year boundary
# can shift into a different local civil date/year in Asia/Colombo.
python3 - <<'PY'
from datetime import timezone
from zoneinfo import ZoneInfo
try:
from skyfield.api import load
from skyfield import almanac
except Exception as e:
print("SKYFIELD_IMPORT_ERROR:", type(e).__name__, e)
raise SystemExit(0)
ts = load.timescale()
eph = load('de421.bsp')
SL = ZoneInfo("Asia/Colombo")
for year in range(2020, 2031):
start = ts.utc(year, 1, 1)
end = ts.utc(year + 1, 1, 1)
times, phases = almanac.find_discrete(start, end, almanac.moon_phases(eph))
for t, phase in zip(times, phases):
if phase == 2:
utc_dt = t.utc_datetime().replace(tzinfo=timezone.utc)
local_dt = utc_dt.astimezone(SL)
if utc_dt.year != local_dt.year:
print(
"YEAR_MISMATCH",
year,
"utc=", utc_dt.isoformat(),
"local=", local_dt.isoformat(),
)
PYRepository: vacanza/holidays Length of output: 224 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Probe the exact function behavior in the repo without executing repo code:
# emulate the logic of find_full_moons()/generate_poya_dates() against Skyfield.
python3 - <<'PY'
from datetime import timezone
from zoneinfo import ZoneInfo
try:
from skyfield.api import load
from skyfield import almanac
except Exception as e:
print("SKYFIELD_IMPORT_ERROR:", type(e).__name__, e)
raise SystemExit(0)
ts = load.timescale()
eph = load('de421.bsp')
SL = ZoneInfo("Asia/Colombo")
hits = []
for year in range(1900, 2051):
start = ts.utc(year, 1, 1)
end = ts.utc(year + 1, 1, 1)
times, phases = almanac.find_discrete(start, end, almanac.moon_phases(eph))
for t, phase in zip(times, phases):
if phase == 2:
utc_dt = t.utc_datetime().replace(tzinfo=timezone.utc)
local_dt = utc_dt.astimezone(SL)
if utc_dt.year != local_dt.year:
hits.append((year, utc_dt.isoformat(), local_dt.isoformat()))
print("HITS", len(hits))
for h in hits[:20]:
print("YEAR_MISMATCH", *h)
PYRepository: vacanza/holidays Length of output: 224 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Check how generate_poya_dates() is consumed and whether the year is
# interpreted from the local civil date or the UTC search window.
rg -n "generate_poya_dates\(|find_full_moons\(" -S .Repository: vacanza/holidays Length of output: 394 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Look for tests or documentation around poya date boundaries.
rg -n "poya|full moon|Sinhala" -S tests holidays docs .Repository: vacanza/holidays Length of output: 33835 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect nearby code for any year-based assumptions around the returned dates.
sed -n '1,260p' holidays/calendars/sinhala_astronomy.py | cat -nRepository: vacanza/holidays Length of output: 7310 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
# Pure date-boundary sanity probe: if a UTC instant near year-end is converted
# to Asia/Colombo, it can land in the next local year and still be excluded
# from the next UTC year window.
utc = timezone.utc
sl = ZoneInfo("Asia/Colombo")
for dt in [
datetime(2024, 12, 31, 18, 31, tzinfo=utc), # just after local midnight
datetime(2024, 12, 31, 20, 0, tzinfo=utc),
]:
local = dt.astimezone(sl)
print(dt.isoformat(), "->", local.isoformat(), "UTC year", dt.year, "local year", local.year)
PYRepository: vacanza/holidays Length of output: 2378 🌐 Web query:
💡 Result: There is no full moon on December 31, 2026. The final full moon of December 2026 occurred on December 24, 2026, at 01:28 UTC [1]. By December 31, 2026, the moon had progressed to a waning crescent phase [2][3], following a last quarter phase on December 30, 2026 [4][1][5]. This holds true regardless of the time zone, including in Sri Lanka (UTC+5:30) [6][7][8]. Citations:
🌐 Web query:
💡 Result: There is no Full Moon on December 31, 2026, or January 1, 2027. Based on the lunar cycle, the December 2026 Full Moon occurs on December 23, 2026 [1]. The subsequent Full Moon, known as the Wolf Moon, occurs on January 22, 2027 [2][3]. Therefore, neither date coincides with a Full Moon in UTC or Sri Lanka time. Citations:
🌐 Web query:
💡 Result: Based on lunar cycle data, here is the information regarding Full Moons on or around December 31 for the requested years: - December 31, 2024: There is no Full Moon on this date. The nearest full moon occurred on December 15, 2024 [1][2]. - December 31, 2025: There is no Full Moon on this date. The last full moon of 2025 occurred on December 4, 2025 [3][4]. - December 31, 2027: There is no Full Moon on this date. The nearest full moon occurred on December 13, 2027 [5]. - December 31, 2028: There is a Full Moon on this date. It occurs at 08:48 UTC [6]. Because this is the second full moon to occur in the same calendar month, it is also known as a Blue Moon [6]. Regarding January 1 in Sri Lanka: - January 1, 2025: There is no Full Moon on this date. The full moon occurred on January 14, 2025 (as seen in data for nearby locations like Bengaluru and Sri Lanka) [7][8]. Citations:
🌐 Web query:
💡 Result: The astronomical full moon is a global event that occurs at a single, precise moment in time, regardless of an observer's location [1][2][3]. Because this event is defined by the physical alignment of the Earth, Moon, and Sun [2][4], the date and time of the full moon are converted into local time zones by adding or subtracting the relevant time offset from Coordinated Universal Time (UTC) [1][3]. An example of a full moon crossing a year boundary between December 31 and January 1 occurs when the precise UTC time of the full moon is close enough to midnight that time zone offsets push the date into the following year for some locations. A notable past example occurred on December 31, 2028 (at 16:48 UTC), which resulted in the full moon falling on January 1, 2029, in time zones east of UTC [5]. Regarding your specific interest in UTC+5:30 (the time zone for India): - A full moon occurring late on December 31 in UTC (e.g., between 18:30 and 23:59 UTC) would shift to January 1 in UTC+5:30 because the local time would be 5 hours and 30 minutes ahead of UTC. - In the case of the 2028 event (16:48 UTC), it was already January 1, 2029, in India (at 22:18 IST) [5]. Conversely, if a full moon occurs very early on January 1 in UTC (e.g., between 00:00 and 05:30 UTC), it would still be December 31 in regions to the west of the prime meridian, while being January 1 in UTC+5:30. Because of this, the calendar date of the full moon can differ across the globe, even though the astronomical event is simultaneous for everyone [1][2]. Citations:
🌐 Web query:
💡 Result: The full moon on December 31, 2028, will occur at 08:48 UTC [1]. It is also noted as a "Blue Moon," as it is the second full moon to occur within the same calendar month [1]. Citations: Full moons near UTC year boundaries can land in the next local year. 🧰 Tools🪛 Ruff (0.15.20)[warning] 192-192: Add explicit value for parameter (B905) 🤖 Prompt for AI Agents |
||||||
|
|
||||||
| return poya_dates | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 201
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 9050
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 911
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 314
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 183
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 7462
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 1645
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 184
🏁 Script executed:
Repository: vacanza/holidays
Length of output: 2876
Declare
skyfieldas a runtime dependencyholidays/calendars/sinhala_astronomy.pyimportsskyfieldat module load time, butpyproject.tomlonly listspython-dateutil. Addskyfieldtoproject.dependencieswith a version pin so the Sinhala astronomy path doesn’t fail on a clean install.🤖 Prompt for AI Agents