#!/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, 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()