- 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>
226 lines
6.9 KiB
Python
226 lines
6.9 KiB
Python
#!/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 1900–2150 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:
|
||
# 農曆年:11–12 月屬上一農曆年,其它屬當年
|
||
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()
|