Skip to content
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions holidays/calendars/sinhala_astronomy.py
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
Comment on lines +23 to +25

Copy link
Copy Markdown
Contributor

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:

#!/bin/bash
# Confirm skyfield is not declared anywhere in project metadata.
fd -t f 'pyproject.toml|setup.cfg|setup.py|requirements.*txt' | while read -r f; do
  echo "== $f =="
  rg -n -i 'skyfield' "$f" || echo "  (no skyfield reference)"
done

Repository: vacanza/holidays

Length of output: 201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== metadata files =="
git ls-files 'pyproject.toml' 'setup.cfg' 'setup.py' 'requirements*.txt' 'holidays/calendars/sinhala_astronomy.py'

echo
echo "== pyproject.toml (relevant sections) =="
sed -n '1,260p' pyproject.toml

echo
echo "== sinhala_astronomy.py (relevant lines) =="
cat -n holidays/calendars/sinhala_astronomy.py | sed -n '1,120p'

Repository: vacanza/holidays

Length of output: 9050


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import tomllib

pyproject = Path("pyproject.toml")
data = tomllib.loads(pyproject.read_text())

# Print dependency-related keys compactly
project = data.get("project", {})
print("project.dependencies:", project.get("dependencies"))
print("project.optional-dependencies:", project.get("optional-dependencies"))
print("tool.poetry.dependencies:", data.get("tool", {}).get("poetry", {}).get("dependencies"))
print("dependency-groups:", data.get("dependency-groups"))
PY

Repository: vacanza/holidays

Length of output: 911


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import ast

path = Path("holidays/calendars/sinhala_astronomy.py")
tree = ast.parse(path.read_text(), filename=str(path))
imports = []
for node in tree.body:
    if isinstance(node, ast.ImportFrom):
        imports.append((node.level, node.module, [alias.name for alias in node.names]))
    elif isinstance(node, ast.Import):
        imports.append((0, None, [alias.name for alias in node.names]))
print(imports)
PY

Repository: vacanza/holidays

Length of output: 314


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import tomllib

data = tomllib.loads(Path("pyproject.toml").read_text())
for key in ["project", "tool"]:
    if key in data:
        print(key, "present")

# show any mention of skyfield across repo metadata if present
for p in [Path("pyproject.toml"), Path("setup.cfg"), Path("setup.py")]:
    if p.exists():
        text = p.read_text()
        if "skyfield" in text.lower():
            print(f"{p}: contains skyfield")
PY

Repository: vacanza/holidays

Length of output: 183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== skyfield references =="
rg -n -i '\bskyfield\b' .

echo
echo "== calendar package imports =="
cat -n holidays/calendars/__init__.py | sed -n '1,220p'

echo
echo "== any Sinhala calendar entry points =="
rg -n -i 'sinhala_astronomy|Sinhala' holidays

Repository: vacanza/holidays

Length of output: 7462


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import ast

for path_str in ["holidays/calendars/__init__.py", "holidays/__init__.py"]:
    path = Path(path_str)
    print(f"== {path_str} ==")
    tree = ast.parse(path.read_text(), filename=path_str)
    for node in tree.body:
        if isinstance(node, ast.ImportFrom):
            print("from", "." * node.level + (node.module or ""), "import", [a.name for a in node.names])
        elif isinstance(node, ast.Import):
            print("import", [a.name for a in node.names])
PY

Repository: vacanza/holidays

Length of output: 1645


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import ast

path = Path("holidays/calendars/sinhala_astronomy.py")
tree = ast.parse(path.read_text(), filename=str(path))
# Detect whether skyfield imports are guarded
for node in tree.body:
    if isinstance(node, ast.Try):
        print("found try block guarding imports")
        break
else:
    print("no try block guarding imports")
PY

Repository: vacanza/holidays

Length of output: 184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for path_str in ["holidays/calendars/__init__.py", "holidays/__init__.py"]:
    path = Path(path_str)
    print(f"== {path_str} ==")
    print(path.read_text())
PY

Repository: vacanza/holidays

Length of output: 2876


Declare skyfield as a runtime dependency
holidays/calendars/sinhala_astronomy.py imports skyfield at module load time, but pyproject.toml only lists python-dateutil. Add skyfield to project.dependencies with a version pin so the Sinhala astronomy path doesn’t fail on a clean install.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holidays/calendars/sinhala_astronomy.py` around lines 23 - 25, The
module-level imports in sinhala_astronomy.py depend on skyfield, but it is not
declared as a runtime dependency, so add skyfield to project.dependencies in
pyproject.toml with an explicit version pin. Update the dependency list used by
the Sinhala astronomy path so imports like skyfield.almanac and skyfield.api in
sinhala_astronomy.py resolve on a clean install.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: skyfield is imported unconditionally here but is not declared as a project dependency. A clean install of this package will fail with ModuleNotFoundError when this module is loaded. skyfield (with a version pin) needs to be added to project.dependencies in pyproject.toml.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At holidays/calendars/sinhala_astronomy.py, line 25:

<comment>`skyfield` is imported unconditionally here but is not declared as a project dependency. A clean install of this package will fail with `ModuleNotFoundError` when this module is loaded. `skyfield` (with a version pin) needs to be added to `project.dependencies` in `pyproject.toml`.</comment>

<file context>
@@ -0,0 +1,221 @@
+
+from skyfield import almanac
+from skyfield.api import Star, load, wgs84
+from skyfield.data import hipparcos
+
+ts = load.timescale()
</file context>


ts = load.timescale()
eph = load("de440s.bsp")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The module eagerly loads astronomical datasets (de440s.bsp ephemeris, Hipparcos star catalog, timescale) at import time. Skyfield's load() downloads missing files from the internet, so importing this module can fail or hang in offline or restricted environments and adds unexpected startup latency. Consider moving the loading behind a lazily-initialized helper (e.g., a cached loader function or a lightweight singleton) so the network/disk IO only happens when the astronomical calculations are actually used.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At holidays/calendars/sinhala_astronomy.py, line 28:

<comment>The module eagerly loads astronomical datasets (`de440s.bsp` ephemeris, Hipparcos star catalog, timescale) at import time. Skyfield's `load()` downloads missing files from the internet, so importing this module can fail or hang in offline or restricted environments and adds unexpected startup latency. Consider moving the loading behind a lazily-initialized helper (e.g., a cached loader function or a lightweight singleton) so the network/disk IO only happens when the astronomical calculations are actually used.</comment>

<file context>
@@ -0,0 +1,221 @@
+from skyfield.data import hipparcos
+
+ts = load.timescale()
+eph = load("de440s.bsp")
+
+earth = eph["earth"]
</file context>


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 de440s.bsp plus the Hipparcos data) and writes those files into the current working directory. For a widely-imported library this is a heavy, surprising side effect: importing the module fails without network access, pollutes the cwd, and slows startup. Consider deferring all of this behind a lazy loader/cache that runs on first use rather than at import.

♻️ Sketch: lazy initialization
_eph = None

def _get_eph():
    global _eph
    if _eph is None:
        _eph = load("de440s.bsp")
    return _eph
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holidays/calendars/sinhala_astronomy.py` around lines 27 - 35, The
module-level initialization in sinhala_astronomy is eagerly downloading and
loading ephemeris/catalog data during import, which creates offline/CI failures
and unexpected side effects. Move the load.timescale(), load("de440s.bsp"), and
hipparcos.load_dataframe(...) work behind a lazy accessor used by the astronomy
functions instead of executing it at import time. Add caching in a helper such
as a private _get_eph/_get_stars-style function so the data is fetched once on
first use and reused afterward.


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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add explicit strict= to zip() calls (Ruff B905).

Flagged here and also at lines 139 and 192. times and phases from find_discrete share a length, so strict=True documents that invariant and future-proofs against silent truncation.

♻️ Proposed fix
-    for t, moon_phase in zip(times, phases):
+    for t, moon_phase in zip(times, phases, strict=True):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for t, moon_phase in zip(times, phases):
for t, moon_phase in zip(times, phases, strict=True):
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 113-113: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holidays/calendars/sinhala_astronomy.py` at line 113, The `zip()` usages in
`sinhala_astronomy.py` are missing an explicit `strict=` argument, which Ruff
flags as B905. Update the `for t, moon_phase in zip(times, phases)` loop and the
other `zip()` calls in this module (including the ones near the `find_discrete`
results) to pass `strict=True`, since the paired sequences are expected to be
the same length and this makes that invariant explicit.

Source: 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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 find_full_moons(year) searches within UTC year boundaries, this event is emitted under year rather than year + 1, and conversely, a Jan 1 UTC full moon that's still Dec 31 locally won't appear. Consider filtering on local_datetime.date().year == year or adjusting the search window to account for the timezone offset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At holidays/calendars/sinhala_astronomy.py, line 210:

<comment>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 `find_full_moons(year)` searches within UTC year boundaries, this event is emitted under `year` rather than `year + 1`, and conversely, a Jan 1 UTC full moon that's still Dec 31 locally won't appear. Consider filtering on `local_datetime.date().year == year` or adjusting the search window to account for the timezone offset.</comment>

<file context>
@@ -0,0 +1,221 @@
+
+    poya_dates = []
+
+    for full_moon in find_full_moons(year):
+        local_datetime = full_moon.utc_datetime().astimezone(SL_TIMEZONE)
+
</file context>

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 -n

Repository: 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(),
                )
PY

Repository: 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)
PY

Repository: 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 -n

Repository: 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)
PY

Repository: vacanza/holidays

Length of output: 2378


🌐 Web query:

full moon December 31 UTC Sri Lanka 5:30 year boundary

💡 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:

full moon December 31 UTC site:timeanddate.com moon Sri Lanka January 1

💡 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:

full moon December 31 UTC site:timeanddate.com/moon/ 2024 2025 2027 2028 January 1 Sri Lanka

💡 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:

full moon December 31 UTC +5:30 January 1 local year boundary example

💡 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:

site:timeanddate.com/moon/phases/?year=2028 "December 31, 2028" full moon UTC

💡 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. find_full_moons() scans a UTC year window, so a moon like Dec 31, 2028 08:48 UTC becomes Jan 1, 2029 in Sri Lanka and is emitted under 2028, not 2029. Filter on local_datetime.date().year or search in local time.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 192-192: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holidays/calendars/sinhala_astronomy.py` around lines 179 - 219, The
full-moon search in find_full_moons() is anchored to a UTC year, so events near
the boundary can be assigned to the wrong local Gregorian year in
generate_poya_dates(). Update the logic to determine poya dates by local Sri
Lanka time instead of raw UTC, either by filtering on local_datetime.date().year
in generate_poya_dates() or by searching the moon phases using a local-time
window so boundary moons are returned under the correct year.


return poya_dates