Files
lunar-converter/lunar_calendar.py
Timmy ae21a0ae66 Initial commit: Lunar calendar converter project
- Add lunar calendar astronomical calculation engine (lunar_calendar.py)
- Add converter utilities for solar/lunar date conversion
- Add historical era name conversions (chrono_converter.py, converter_with_eras.py)
- Add FastAPI REST API for date conversion services
- Add age calculator using lunar calendar
- Add batch generation script for lunar calendar JSON data
- Add project documentation (CLAUDE.md, pyproject.toml)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 13:51:45 +08:00

226 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "skyfield",
# "jplephem",
# "numpy",
# ]
# ///
import json
from datetime import date, timedelta
from typing import Any, cast
from skyfield.almanac import find_discrete, moon_phases
from skyfield.api import load
from skyfield.framelib import ecliptic_frame
class LunarCalendar:
"""利用 Skyfield 計算農曆、干支、生肖、節氣並輸出 JSON。"""
TZ_OFFSET = timedelta(hours=8) # 台北時區
HEAVENLY_STEMS = ["", "", "", "", "", "", "", "", "", ""]
EARTHLY_BRANCHES = [
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
ZODIAC = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
SOLAR_TERMS_24 = {
315: "立春",
330: "雨水",
345: "驚蟄",
0: "春分",
15: "清明",
30: "穀雨",
45: "立夏",
60: "小滿",
75: "芒種",
90: "夏至",
105: "小暑",
120: "大暑",
135: "立秋",
150: "處暑",
165: "白露",
180: "秋分",
195: "寒露",
210: "霜降",
225: "立冬",
240: "小雪",
255: "大雪",
270: "冬至",
285: "小寒",
300: "大寒",
}
def __init__(self, year: int):
self.year = year
self.ts = load.timescale()
# Ephemeris kernel options:
# - de422.bsp: default here as it is bundled in this repo.
# - de440s.bsp: newer, compact; suitable for 19002150 use-cases.
# - de421.bsp: older but still usable for compatibility.
# Switch by uncommenting one of the lines below and ensure the file
# exists in the working directory (or provide an absolute path).
# self.eph = load('de421.bsp')
# self.eph = load('de440s.bsp')
self.eph = load("de422.bsp")
# Pylance sometimes mis-infers types.
# Cast to Any to avoid false positives.
self.earth = cast(Any, self.eph["earth"]) # has method .at(t)
self.sun = cast(Any, self.eph["sun"]) # used with .observe()
def _compute_astronomy(self):
"""抓新朔與 24 節氣時間點"""
t0 = self.ts.utc(self.year - 1, 11, 1)
t1 = self.ts.utc(self.year + 1, 3, 1)
# 每月朔
times, phases = find_discrete(t0, t1, moon_phases(self.eph))
new_moons = [t for t, p in zip(times, phases, strict=False) if p == 0]
# 每 15° 的節氣
def term_index(t):
lat, lon, _ = (
self.earth.at(t)
.observe(self.sun)
.apparent()
.frame_latlon(ecliptic_frame)
)
return (lon.degrees // 15).astype(int)
term_index.step_days = 1
st_times, st_idxs = find_discrete(t0, t1, term_index)
zhongqi = [
(t, (idx * 15) % 360) for t, idx in zip(st_times, st_idxs, strict=False)
]
return new_moons, zhongqi
def _label_months(self, new_moons, zhongqi):
"""根據冬至、中氣規則標記每段朔月的月號與閏月屬性"""
cutoff = date(self.year, 1, 1)
sols = [
t
for t, deg in zhongqi
if deg == 270 and (t.utc_datetime() + self.TZ_OFFSET).date() < cutoff
]
solstice = max(sols)
i0 = max(
i
for i, t in enumerate(new_moons)
if t.utc_datetime() < solstice.utc_datetime()
)
labels = []
month_no = 11
leap_used = False
for j in range(i0, len(new_moons) - 1):
start, end = new_moons[j], new_moons[j + 1]
cnt = sum(1 for t, deg in zhongqi if start < t < end and deg % 30 == 0)
if j == i0:
labels.append((start, end, 11, False))
else:
if cnt == 0 and not leap_used:
labels.append((start, end, month_no, True))
leap_used = True
else:
month_no = month_no % 12 + 1
labels.append((start, end, month_no, False))
return labels
def _ganzhi_year(self, y):
"""計算指定年之干支"""
s = self.HEAVENLY_STEMS[(y - 4) % 10]
b = self.EARTHLY_BRANCHES[(y - 4) % 12]
return s + b
def build_calendar(self):
"""產生從 YYYY-01-01 至 YYYY-12-31 的完整農曆對照表"""
new_moons, zhongqi = self._compute_astronomy()
month_labels = self._label_months(new_moons, zhongqi)
# 節氣快查
solar_terms = {}
for t, deg in zhongqi:
d = (t.utc_datetime() + self.TZ_OFFSET).date().isoformat()
name = self.SOLAR_TERMS_24.get(deg)
if name:
solar_terms[d] = name
result = {}
day = date(self.year, 1, 1)
end = date(self.year, 12, 31)
while day <= end:
for ts, te, m, is_leap in month_labels:
ds = (ts.utc_datetime() + self.TZ_OFFSET).date()
de = (te.utc_datetime() + self.TZ_OFFSET).date()
if ds <= day < de:
# 農曆年1112 月屬上一農曆年,其它屬當年
lunar_year = self.year if 1 <= m <= 10 else self.year - 1
gz_year = self._ganzhi_year(lunar_year)
zodiac = self.ZODIAC[self.EARTHLY_BRANCHES[(lunar_year - 4) % 12]]
offset = (day - ds).days + 1
lunar_str = f"{'' if is_leap else ''}{m}{offset}"
result[day.isoformat()] = {
"西曆": day.isoformat(),
"農曆": lunar_str,
"干支": gz_year,
"生肖": zodiac,
"節氣": solar_terms.get(day.isoformat(), ""),
}
break
day += timedelta(days=1)
return result
def save_json(self, filename: str | None = None):
"""將該年日曆輸出為 JSON 檔"""
data = self.build_calendar()
fname = filename or f"{self.year}.json"
with open(fname, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"完成!已輸出:{fname}")
if __name__ == "__main__":
import sys
if len(sys.argv) != 2 or not sys.argv[1].isdigit():
print("用法python3 lunar_calendar.py <年份>")
sys.exit(1)
year = int(sys.argv[1])
cal = LunarCalendar(year)
cal.save_json()