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>
This commit is contained in:
2026-03-09 13:51:45 +08:00
commit ae21a0ae66
19 changed files with 1977 additions and 0 deletions

225
lunar_calendar.py Normal file
View File

@@ -0,0 +1,225 @@
#!/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()