Files
lunar-converter/lunar_calendar.py
Timmy 0175a07cf1 Fix save_json to save files to lunar_json directory
- Add output_dir parameter (default: "lunar_json")
- Auto-create directory if not exists
- Update output message to show full file path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 14:28:16 +08:00

230 lines
7.0 KiB
Python
Raw Permalink 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, output_dir: str = "lunar_json"):
"""將該年日曆輸出為 JSON 檔"""
import os
data = self.build_calendar()
os.makedirs(output_dir, exist_ok=True)
fname = filename or f"{self.year}.json"
filepath = os.path.join(output_dir, fname)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"完成!已輸出:{filepath}")
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()