#!/usr/bin/env python3 # /// script # requires-python = ">=3.13" # dependencies = [ # "skyfield", # "jplephem", # "numpy", # ] # /// import json from datetime import date, datetime, timedelta from skyfield.api import load from skyfield.framelib import ecliptic_frame from skyfield.almanac import find_discrete, moon_phases 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() # self.eph = load('de421.bsp') self.eph = load('de440s.bsp') self.earth= self.eph['earth'] self.sun = self.eph['sun'] 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) 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)] 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): """將該年日曆輸出為 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()