converter.py
· 3.4 KiB · Python
Sin formato
#!/usr/bin/env python3
# converter.py
# 用於西曆與農曆互轉,已封裝成類別
import os
import json
import argparse
from datetime import datetime
class Converter:
"""
提供西曆與農曆互轉功能:
- solar_to_lunar(date_str:str) -> dict
- lunar_to_solar(year:int, month:int, day:int, leap:bool) -> str
"""
LUNAR_JSON_DIR = 'lunar_json'
@classmethod
def load_solar_to_lunar(cls, year: int) -> dict:
path = os.path.join(cls.LUNAR_JSON_DIR, f"{year}.json")
if not os.path.exists(path):
raise FileNotFoundError(f"找不到 {path}")
with open(path, encoding='utf-8') as f:
return json.load(f)
def solar_to_lunar(self, solar_date_str: str) -> dict:
try:
dt = datetime.strptime(solar_date_str, '%Y-%m-%d').date()
except ValueError:
raise ValueError("西曆日期格式應為 YYYY-MM-DD")
data = self.load_solar_to_lunar(dt.year)
if solar_date_str not in data:
raise KeyError(f"沒有找到對應 {solar_date_str} 的農曆資料")
return data[solar_date_str]
def lunar_to_solar(self, lunar_year: int, lunar_month: int, lunar_day: int, leap: bool=False) -> str:
solar_year = lunar_year + 1 if lunar_month in (11, 12) else lunar_year
data = self.load_solar_to_lunar(solar_year)
# 精確匹配
for solar, info in data.items():
lstr = info.get('農曆','')
is_leap = lstr.startswith('閏')
core = lstr[1:] if is_leap else lstr
m_str, d_str = core.replace('月','-').replace('日','').split('-')
if int(m_str)==lunar_month and int(d_str)==lunar_day and is_leap==leap:
return solar
# 回退到非閏月匹配
for solar, info in data.items():
lstr = info.get('農曆','')
is_leap = lstr.startswith('閏')
core = lstr[1:] if is_leap else lstr
m_str, d_str = core.replace('月','-').replace('日','').split('-')
if int(m_str)==lunar_month and int(d_str)==lunar_day and not is_leap:
return solar
raise KeyError(f"找不到對應農曆 {lunar_year}{'閏' if leap else ''}{lunar_month}月{lunar_day}日 的西曆")
def main():
parser = argparse.ArgumentParser(description='西曆與農曆互轉工具')
sub = parser.add_subparsers(dest='command', required=True)
# 西曆 -> 農曆
p1 = sub.add_parser('solar2lunar', help='西曆轉農曆')
p1.add_argument('date', type=str, help='西曆日期 YYYY-MM-DD')
# 農曆 -> 西曆
p2 = sub.add_parser('lunar2solar', help='農曆轉西曆')
p2.add_argument('date', type=str, help='農曆日期 YYYY-MM-DD')
p2.add_argument('--leap', action='store_true', help='是否閏月')
args = parser.parse_args()
conv = Converter()
if args.command == 'solar2lunar':
info = conv.solar_to_lunar(args.date)
print(json.dumps(info, ensure_ascii=False, indent=2))
elif args.command == 'lunar2solar':
try:
y_str, m_str, d_str = args.date.split('-',2)
y, m, d = int(y_str), int(m_str), int(d_str)
except Exception:
raise ValueError("農曆日期格式應為 YYYY-MM-DD,閏月請加 --leap")
solar = conv.lunar_to_solar(y, m, d, args.leap)
info = conv.solar_to_lunar(solar)
print(json.dumps(info, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
| 1 | #!/usr/bin/env python3 |
| 2 | # converter.py |
| 3 | # 用於西曆與農曆互轉,已封裝成類別 |
| 4 | |
| 5 | import os |
| 6 | import json |
| 7 | import argparse |
| 8 | from datetime import datetime |
| 9 | |
| 10 | class Converter: |
| 11 | """ |
| 12 | 提供西曆與農曆互轉功能: |
| 13 | - solar_to_lunar(date_str:str) -> dict |
| 14 | - lunar_to_solar(year:int, month:int, day:int, leap:bool) -> str |
| 15 | """ |
| 16 | LUNAR_JSON_DIR = 'lunar_json' |
| 17 | |
| 18 | @classmethod |
| 19 | def load_solar_to_lunar(cls, year: int) -> dict: |
| 20 | path = os.path.join(cls.LUNAR_JSON_DIR, f"{year}.json") |
| 21 | if not os.path.exists(path): |
| 22 | raise FileNotFoundError(f"找不到 {path}") |
| 23 | with open(path, encoding='utf-8') as f: |
| 24 | return json.load(f) |
| 25 | |
| 26 | def solar_to_lunar(self, solar_date_str: str) -> dict: |
| 27 | try: |
| 28 | dt = datetime.strptime(solar_date_str, '%Y-%m-%d').date() |
| 29 | except ValueError: |
| 30 | raise ValueError("西曆日期格式應為 YYYY-MM-DD") |
| 31 | data = self.load_solar_to_lunar(dt.year) |
| 32 | if solar_date_str not in data: |
| 33 | raise KeyError(f"沒有找到對應 {solar_date_str} 的農曆資料") |
| 34 | return data[solar_date_str] |
| 35 | |
| 36 | def lunar_to_solar(self, lunar_year: int, lunar_month: int, lunar_day: int, leap: bool=False) -> str: |
| 37 | solar_year = lunar_year + 1 if lunar_month in (11, 12) else lunar_year |
| 38 | data = self.load_solar_to_lunar(solar_year) |
| 39 | # 精確匹配 |
| 40 | for solar, info in data.items(): |
| 41 | lstr = info.get('農曆','') |
| 42 | is_leap = lstr.startswith('閏') |
| 43 | core = lstr[1:] if is_leap else lstr |
| 44 | m_str, d_str = core.replace('月','-').replace('日','').split('-') |
| 45 | if int(m_str)==lunar_month and int(d_str)==lunar_day and is_leap==leap: |
| 46 | return solar |
| 47 | # 回退到非閏月匹配 |
| 48 | for solar, info in data.items(): |
| 49 | lstr = info.get('農曆','') |
| 50 | is_leap = lstr.startswith('閏') |
| 51 | core = lstr[1:] if is_leap else lstr |
| 52 | m_str, d_str = core.replace('月','-').replace('日','').split('-') |
| 53 | if int(m_str)==lunar_month and int(d_str)==lunar_day and not is_leap: |
| 54 | return solar |
| 55 | raise KeyError(f"找不到對應農曆 {lunar_year}{'閏' if leap else ''}{lunar_month}月{lunar_day}日 的西曆") |
| 56 | |
| 57 | |
| 58 | def main(): |
| 59 | parser = argparse.ArgumentParser(description='西曆與農曆互轉工具') |
| 60 | sub = parser.add_subparsers(dest='command', required=True) |
| 61 | |
| 62 | # 西曆 -> 農曆 |
| 63 | p1 = sub.add_parser('solar2lunar', help='西曆轉農曆') |
| 64 | p1.add_argument('date', type=str, help='西曆日期 YYYY-MM-DD') |
| 65 | |
| 66 | # 農曆 -> 西曆 |
| 67 | p2 = sub.add_parser('lunar2solar', help='農曆轉西曆') |
| 68 | p2.add_argument('date', type=str, help='農曆日期 YYYY-MM-DD') |
| 69 | p2.add_argument('--leap', action='store_true', help='是否閏月') |
| 70 | |
| 71 | args = parser.parse_args() |
| 72 | conv = Converter() |
| 73 | |
| 74 | if args.command == 'solar2lunar': |
| 75 | info = conv.solar_to_lunar(args.date) |
| 76 | print(json.dumps(info, ensure_ascii=False, indent=2)) |
| 77 | elif args.command == 'lunar2solar': |
| 78 | try: |
| 79 | y_str, m_str, d_str = args.date.split('-',2) |
| 80 | y, m, d = int(y_str), int(m_str), int(d_str) |
| 81 | except Exception: |
| 82 | raise ValueError("農曆日期格式應為 YYYY-MM-DD,閏月請加 --leap") |
| 83 | solar = conv.lunar_to_solar(y, m, d, args.leap) |
| 84 | info = conv.solar_to_lunar(solar) |
| 85 | print(json.dumps(info, ensure_ascii=False, indent=2)) |
| 86 | |
| 87 | if __name__ == '__main__': |
| 88 | main() |
| 89 |
flask_api.py
· 3.0 KiB · Python
Sin formato
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "flask",
# ]
# ///
import socket
def find_free_port(start=5000, max_tries=100):
"""
從 start 開始找一個未被監聽的 TCP 端口,最多嘗試 max_tries 次
"""
port = start
for _ in range(max_tries):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if s.connect_ex(('0.0.0.0', port)) != 0:
return port
port += 1
raise RuntimeError(f"在 {start}–{start+max_tries} 範圍內找不到可用端口")
import json
from flask import Flask, request, Response
from converter import Converter
app = Flask(__name__)
conv = Converter()
@app.route('/solar2lunar', methods=['GET'])
def solar2lunar_endpoint():
date_str = request.args.get('date')
if not date_str:
return Response(json.dumps({'error': '缺少參數 date,格式 YYYY-MM-DD'}), mimetype='application/json; charset=utf-8'), 400
try:
info_raw = conv.solar_to_lunar(date_str)
info = {
'gregorian': info_raw['西曆'], # Gregorian date
'lunar': info_raw['農曆'], # Lunar date
'ganzhi': info_raw['干支'], # Heavenly Stem and Earthly Branch
'zodiac': info_raw['生肖'], # Zodiac animal
'solar_term': info_raw['節氣'] # Solar term
}
return Response(json.dumps(info, ensure_ascii=False), mimetype='application/json; charset=utf-8')
except Exception as e:
return Response(json.dumps({'error': str(e)}, ensure_ascii=False), mimetype='application/json; charset=utf-8'), 400
@app.route('/lunar2solar', methods=['GET'])
def lunar2solar_endpoint():
date_str = request.args.get('date')
leap_str = request.args.get('leap', 'false')
if not date_str:
return Response(json.dumps({'error': '缺少參數 date,格式 YYYY-MM-DD'}), mimetype='application/json; charset=utf-8'), 400
leap = leap_str.lower() in ('1', 'true', 'yes')
try:
y_str, m_str, d_str = date_str.split('-', 2)
y, m, d = int(y_str), int(m_str), int(d_str)
except Exception:
return Response(json.dumps({'error': '農曆日期格式應為 YYYY-MM-DD,閏月請加 leap=true'}), mimetype='application/json; charset=utf-8'), 400
try:
solar_date = conv.lunar_to_solar(y, m, d, leap)
info_raw = conv.solar_to_lunar(solar_date)
info = {
'gregorian': info_raw['西曆'],
'lunar': info_raw['農曆'],
'ganzhi': info_raw['干支'],
'zodiac': info_raw['生肖'],
'solar_term': info_raw['節氣']
}
return Response(json.dumps(info, ensure_ascii=False), mimetype='application/json; charset=utf-8')
except Exception as e:
return Response(json.dumps({'error': str(e)}, ensure_ascii=False), mimetype='application/json; charset=utf-8'), 400
if __name__ == '__main__':
port = find_free_port(5000)
print(f"使用埠 {port} 啟動服務…")
app.run(host='0.0.0.0', port=port)
| 1 | # /// script |
| 2 | # requires-python = ">=3.13" |
| 3 | # dependencies = [ |
| 4 | # "flask", |
| 5 | # ] |
| 6 | # /// |
| 7 | import socket |
| 8 | |
| 9 | def find_free_port(start=5000, max_tries=100): |
| 10 | """ |
| 11 | 從 start 開始找一個未被監聽的 TCP 端口,最多嘗試 max_tries 次 |
| 12 | """ |
| 13 | port = start |
| 14 | for _ in range(max_tries): |
| 15 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| 16 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 17 | if s.connect_ex(('0.0.0.0', port)) != 0: |
| 18 | return port |
| 19 | port += 1 |
| 20 | raise RuntimeError(f"在 {start}–{start+max_tries} 範圍內找不到可用端口") |
| 21 | |
| 22 | import json |
| 23 | from flask import Flask, request, Response |
| 24 | from converter import Converter |
| 25 | |
| 26 | app = Flask(__name__) |
| 27 | conv = Converter() |
| 28 | |
| 29 | @app.route('/solar2lunar', methods=['GET']) |
| 30 | def solar2lunar_endpoint(): |
| 31 | date_str = request.args.get('date') |
| 32 | if not date_str: |
| 33 | return Response(json.dumps({'error': '缺少參數 date,格式 YYYY-MM-DD'}), mimetype='application/json; charset=utf-8'), 400 |
| 34 | try: |
| 35 | info_raw = conv.solar_to_lunar(date_str) |
| 36 | info = { |
| 37 | 'gregorian': info_raw['西曆'], # Gregorian date |
| 38 | 'lunar': info_raw['農曆'], # Lunar date |
| 39 | 'ganzhi': info_raw['干支'], # Heavenly Stem and Earthly Branch |
| 40 | 'zodiac': info_raw['生肖'], # Zodiac animal |
| 41 | 'solar_term': info_raw['節氣'] # Solar term |
| 42 | } |
| 43 | return Response(json.dumps(info, ensure_ascii=False), mimetype='application/json; charset=utf-8') |
| 44 | except Exception as e: |
| 45 | return Response(json.dumps({'error': str(e)}, ensure_ascii=False), mimetype='application/json; charset=utf-8'), 400 |
| 46 | |
| 47 | @app.route('/lunar2solar', methods=['GET']) |
| 48 | def lunar2solar_endpoint(): |
| 49 | date_str = request.args.get('date') |
| 50 | leap_str = request.args.get('leap', 'false') |
| 51 | if not date_str: |
| 52 | return Response(json.dumps({'error': '缺少參數 date,格式 YYYY-MM-DD'}), mimetype='application/json; charset=utf-8'), 400 |
| 53 | leap = leap_str.lower() in ('1', 'true', 'yes') |
| 54 | try: |
| 55 | y_str, m_str, d_str = date_str.split('-', 2) |
| 56 | y, m, d = int(y_str), int(m_str), int(d_str) |
| 57 | except Exception: |
| 58 | return Response(json.dumps({'error': '農曆日期格式應為 YYYY-MM-DD,閏月請加 leap=true'}), mimetype='application/json; charset=utf-8'), 400 |
| 59 | try: |
| 60 | solar_date = conv.lunar_to_solar(y, m, d, leap) |
| 61 | info_raw = conv.solar_to_lunar(solar_date) |
| 62 | info = { |
| 63 | 'gregorian': info_raw['西曆'], |
| 64 | 'lunar': info_raw['農曆'], |
| 65 | 'ganzhi': info_raw['干支'], |
| 66 | 'zodiac': info_raw['生肖'], |
| 67 | 'solar_term': info_raw['節氣'] |
| 68 | } |
| 69 | return Response(json.dumps(info, ensure_ascii=False), mimetype='application/json; charset=utf-8') |
| 70 | except Exception as e: |
| 71 | return Response(json.dumps({'error': str(e)}, ensure_ascii=False), mimetype='application/json; charset=utf-8'), 400 |
| 72 | |
| 73 | if __name__ == '__main__': |
| 74 | port = find_free_port(5000) |
| 75 | print(f"使用埠 {port} 啟動服務…") |
| 76 | app.run(host='0.0.0.0', port=port) |
| 77 |
generate_lunar_1900_2100.py
· 680 B · Python
Sin formato
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "skyfield",
# ]
# ///
# requires-python = ">=3.13"
# dependencies = [
# "skyfield",
# "jplephem",
# "numpy",
# ]
import os
from lunar_calendar import LunarCalendar # 假設類別存於 lunar_calendar.py
def main():
# 輸出目錄
output_dir = 'lunar_json'
os.makedirs(output_dir, exist_ok=True)
# 產生 1900~2100 年,每年一個 JSON 檔:<year>.json
for year in range(1900, 2101):
cal = LunarCalendar(year)
filename = os.path.join(output_dir, f"{year}.json")
cal.save_json(filename)
if __name__ == "__main__":
main()
| 1 | #!/usr/bin/env python3 |
| 2 | # /// script |
| 3 | # requires-python = ">=3.13" |
| 4 | # dependencies = [ |
| 5 | # "skyfield", |
| 6 | # ] |
| 7 | # /// |
| 8 | |
| 9 | # requires-python = ">=3.13" |
| 10 | # dependencies = [ |
| 11 | # "skyfield", |
| 12 | # "jplephem", |
| 13 | # "numpy", |
| 14 | # ] |
| 15 | import os |
| 16 | |
| 17 | from lunar_calendar import LunarCalendar # 假設類別存於 lunar_calendar.py |
| 18 | |
| 19 | def main(): |
| 20 | # 輸出目錄 |
| 21 | output_dir = 'lunar_json' |
| 22 | os.makedirs(output_dir, exist_ok=True) |
| 23 | |
| 24 | # 產生 1900~2100 年,每年一個 JSON 檔:<year>.json |
| 25 | for year in range(1900, 2101): |
| 26 | cal = LunarCalendar(year) |
| 27 | filename = os.path.join(output_dir, f"{year}.json") |
| 28 | cal.save_json(filename) |
| 29 | |
| 30 | if __name__ == "__main__": |
| 31 | main() |
| 32 | |
| 33 |
lunar_calendar.py
· 5.7 KiB · Python
Sin formato
#!/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()
| 1 | #!/usr/bin/env python3 |
| 2 | # /// script |
| 3 | # requires-python = ">=3.13" |
| 4 | # dependencies = [ |
| 5 | # "skyfield", |
| 6 | # "jplephem", |
| 7 | # "numpy", |
| 8 | # ] |
| 9 | # /// |
| 10 | |
| 11 | import json |
| 12 | from datetime import date, datetime, timedelta |
| 13 | from skyfield.api import load |
| 14 | from skyfield.framelib import ecliptic_frame |
| 15 | from skyfield.almanac import find_discrete, moon_phases |
| 16 | |
| 17 | class LunarCalendar: |
| 18 | """利用 Skyfield 計算農曆、干支、生肖、節氣並輸出 JSON。""" |
| 19 | |
| 20 | TZ_OFFSET = timedelta(hours=8) # 台北時區 |
| 21 | |
| 22 | HEAVENLY_STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸'] |
| 23 | EARTHLY_BRANCHES = ['子','丑','寅','卯','辰','巳','午','未','申','酉','戌','亥'] |
| 24 | ZODIAC = { |
| 25 | '子':'鼠','丑':'牛','寅':'虎','卯':'兔','辰':'龍','巳':'蛇', |
| 26 | '午':'馬','未':'羊','申':'猴','酉':'雞','戌':'狗','亥':'豬' |
| 27 | } |
| 28 | SOLAR_TERMS_24 = { |
| 29 | 315:'立春',330:'雨水',345:'驚蟄', 0:'春分', 15:'清明', 30:'穀雨', |
| 30 | 45:'立夏', 60:'小滿', 75:'芒種', 90:'夏至',105:'小暑',120:'大暑', |
| 31 | 135:'立秋',150:'處暑',165:'白露',180:'秋分',195:'寒露',210:'霜降', |
| 32 | 225:'立冬',240:'小雪',255:'大雪',270:'冬至',285:'小寒',300:'大寒', |
| 33 | } |
| 34 | |
| 35 | def __init__(self, year: int): |
| 36 | self.year = year |
| 37 | self.ts = load.timescale() |
| 38 | # self.eph = load('de421.bsp') |
| 39 | self.eph = load('de440s.bsp') |
| 40 | self.earth= self.eph['earth'] |
| 41 | self.sun = self.eph['sun'] |
| 42 | |
| 43 | def _compute_astronomy(self): |
| 44 | """抓新朔與 24 節氣時間點""" |
| 45 | t0 = self.ts.utc(self.year-1, 11, 1) |
| 46 | t1 = self.ts.utc(self.year+1, 3, 1) |
| 47 | |
| 48 | # 每月朔 |
| 49 | times, phases = find_discrete(t0, t1, moon_phases(self.eph)) |
| 50 | new_moons = [t for t,p in zip(times, phases) if p == 0] |
| 51 | |
| 52 | # 每 15° 的節氣 |
| 53 | def term_index(t): |
| 54 | lat, lon, _ = self.earth.at(t).observe(self.sun).apparent() \ |
| 55 | .frame_latlon(ecliptic_frame) |
| 56 | return (lon.degrees // 15).astype(int) |
| 57 | term_index.step_days = 1 |
| 58 | |
| 59 | st_times, st_idxs = find_discrete(t0, t1, term_index) |
| 60 | zhongqi = [(t, (idx * 15) % 360) for t,idx in zip(st_times, st_idxs)] |
| 61 | |
| 62 | return new_moons, zhongqi |
| 63 | |
| 64 | def _label_months(self, new_moons, zhongqi): |
| 65 | """根據冬至、中氣規則標記每段朔月的月號與閏月屬性""" |
| 66 | cutoff = date(self.year,1,1) |
| 67 | sols = [ |
| 68 | t for t,deg in zhongqi |
| 69 | if deg == 270 and (t.utc_datetime() + self.TZ_OFFSET).date() < cutoff |
| 70 | ] |
| 71 | solstice = max(sols) |
| 72 | i0 = max(i for i,t in enumerate(new_moons) |
| 73 | if t.utc_datetime() < solstice.utc_datetime()) |
| 74 | |
| 75 | labels = [] |
| 76 | month_no = 11 |
| 77 | leap_used = False |
| 78 | |
| 79 | for j in range(i0, len(new_moons)-1): |
| 80 | start, end = new_moons[j], new_moons[j+1] |
| 81 | cnt = sum(1 for t,deg in zhongqi |
| 82 | if start < t < end and deg % 30 == 0) |
| 83 | |
| 84 | if j == i0: |
| 85 | labels.append((start, end, 11, False)) |
| 86 | else: |
| 87 | if cnt == 0 and not leap_used: |
| 88 | labels.append((start, end, month_no, True)) |
| 89 | leap_used = True |
| 90 | else: |
| 91 | month_no = month_no % 12 + 1 |
| 92 | labels.append((start, end, month_no, False)) |
| 93 | |
| 94 | return labels |
| 95 | |
| 96 | def _ganzhi_year(self, y): |
| 97 | """計算指定年之干支""" |
| 98 | s = self.HEAVENLY_STEMS[(y - 4) % 10] |
| 99 | b = self.EARTHLY_BRANCHES[(y - 4) % 12] |
| 100 | return s + b |
| 101 | |
| 102 | def build_calendar(self): |
| 103 | """產生從 YYYY-01-01 至 YYYY-12-31 的完整農曆對照表""" |
| 104 | new_moons, zhongqi = self._compute_astronomy() |
| 105 | month_labels = self._label_months(new_moons, zhongqi) |
| 106 | |
| 107 | # 節氣快查 |
| 108 | solar_terms = {} |
| 109 | for t,deg in zhongqi: |
| 110 | d = (t.utc_datetime() + self.TZ_OFFSET).date().isoformat() |
| 111 | name = self.SOLAR_TERMS_24.get(deg) |
| 112 | if name: |
| 113 | solar_terms[d] = name |
| 114 | |
| 115 | result = {} |
| 116 | day = date(self.year,1,1) |
| 117 | end = date(self.year,12,31) |
| 118 | |
| 119 | while day <= end: |
| 120 | for ts, te, m, is_leap in month_labels: |
| 121 | ds = (ts.utc_datetime() + self.TZ_OFFSET).date() |
| 122 | de = (te.utc_datetime() + self.TZ_OFFSET).date() |
| 123 | if ds <= day < de: |
| 124 | # 農曆年:11–12 月屬上一農曆年,其它屬當年 |
| 125 | lunar_year = self.year if 1 <= m <= 10 else self.year-1 |
| 126 | gz_year = self._ganzhi_year(lunar_year) |
| 127 | zodiac = self.ZODIAC[self.EARTHLY_BRANCHES[(lunar_year - 4) % 12]] |
| 128 | |
| 129 | offset = (day - ds).days + 1 |
| 130 | lunar_str = f"{'閏' if is_leap else ''}{m}月{offset}日" |
| 131 | result[day.isoformat()] = { |
| 132 | "西曆": day.isoformat(), |
| 133 | "農曆": lunar_str, |
| 134 | "干支": gz_year, |
| 135 | "生肖": zodiac, |
| 136 | "節氣": solar_terms.get(day.isoformat(), "") |
| 137 | } |
| 138 | break |
| 139 | day += timedelta(days=1) |
| 140 | |
| 141 | return result |
| 142 | |
| 143 | def save_json(self, filename: str = None): |
| 144 | """將該年日曆輸出為 JSON 檔""" |
| 145 | data = self.build_calendar() |
| 146 | fname = filename or f"{self.year}.json" |
| 147 | with open(fname, "w", encoding="utf-8") as f: |
| 148 | json.dump(data, f, ensure_ascii=False, indent=2) |
| 149 | print(f"完成!已輸出:{fname}") |
| 150 | |
| 151 | if __name__ == "__main__": |
| 152 | import sys |
| 153 | if len(sys.argv) != 2 or not sys.argv[1].isdigit(): |
| 154 | print("用法:python3 lunar_calendar.py <年份>") |
| 155 | sys.exit(1) |
| 156 | year = int(sys.argv[1]) |
| 157 | cal = LunarCalendar(year) |
| 158 | cal.save_json() |
| 159 | |
| 160 |