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:
225
lunar_calendar.py
Normal file
225
lunar_calendar.py
Normal 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 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()
|
||||
Reference in New Issue
Block a user