feat(saju-lab): solar_terms.py — sxtwl 기반 24절기 + 月支 매핑

This commit is contained in:
2026-05-25 19:28:33 +09:00
parent 4ddcd75453
commit 07b5c32f2f
2 changed files with 259 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
"""24절기 + sxtwl 기반 月支 계산.
sxtwl(寿星天文历) 2.x의 JieQi index 순서:
0=冬至, 1=小寒, 2=大寒, 3=立春, 4=雨水, 5=驚蟄,
6=春分, 7=清明, 8=穀雨, 9=立夏, 10=小滿, 11=芒種,
12=夏至, 13=小暑, 14=大暑, 15=立秋, 16=處暑, 17=白露,
18=秋分, 19=寒露, 20=霜降, 21=立冬, 22=小雪, 23=大雪
(冬至부터 15° 간격으로 황경 증가 — sxtwl.cpp 의 `xn % 24` 계산식 기준)
saju에서 月支는 12개의 ''(節, 홀수 황경 인덱스에 해당)을 기준으로 12구간으로 나눈다:
立春(3) → 寅(2)
驚蟄(5) → 卯(3)
清明(7) → 辰(4)
立夏(9) → 巳(5)
芒種(11) → 午(6)
小暑(13) → 未(7)
立秋(15) → 申(8)
白露(17) → 酉(9)
寒露(19) → 戌(10)
立冬(21) → 亥(11)
大雪(23) → 子(0)
小寒(1) → 丑(1)
"""
from __future__ import annotations
from datetime import date as _date
from functools import lru_cache
import sxtwl
# sxtwl JieQi index → 月支 인덱스 (子=0, 丑=1, 寅=2, ..., 亥=11)
# 12 '節' 만 매핑 (홀수 인덱스), '氣' (짝수 인덱스)는 月支 경계가 아님 → 직전 절의 月支 유지
JIEQI_TO_BRANCH: dict[int, int] = {
3: 2, # 立春 → 寅
5: 3, # 驚蟄 → 卯
7: 4, # 清明 → 辰
9: 5, # 立夏 → 巳
11: 6, # 芒種 → 午
13: 7, # 小暑 → 未
15: 8, # 立秋 → 申
17: 9, # 白露 → 酉
19: 10, # 寒露 → 戌
21: 11, # 立冬 → 亥
23: 0, # 大雪 → 子
1: 1, # 小寒 → 丑
}
@lru_cache(maxsize=64)
def _all_jieqi_dates(year: int) -> tuple[tuple[_date, int], ...]:
"""주어진 연도의 24절기 (date, jieqi_index) 튜플 리스트.
sxtwl.getJieQiByYear(year) → list[JieQiInfo(jd, jqIndex)] 사용.
sxtwl.JD2DD(jd) → Time(Y, M, D, ...)로 양력 변환.
@lru_cache로 같은 연도 반복 호출 시 sxtwl 재호출 방지.
"""
result: list[tuple[_date, int]] = []
jq_list = sxtwl.getJieQiByYear(year)
for info in jq_list:
t = sxtwl.JD2DD(info.jd)
try:
d = _date(int(t.Y), int(t.M), int(t.D))
except (ValueError, OverflowError):
continue
result.append((d, int(info.jqIndex)))
return tuple(result)
def get_current_solar_term(year: int, month: int, day: int) -> int:
"""현재 날짜에 적용되는 절기 인덱스 (0~23, sxtwl 기준) 반환.
입력 날짜 이전(또는 같은) 가장 가까운 절기의 인덱스를 반환.
절기가 아직 들어오지 않은 경우(연초 등) ±1년치를 함께 탐색.
"""
target = _date(year, month, day)
candidates: list[tuple[_date, int]] = []
for y in (year - 1, year, year + 1):
candidates.extend(_all_jieqi_dates(y))
candidates.sort()
last_idx = 0 # 폴백
for d, idx in candidates:
if d <= target:
last_idx = idx
else:
break
return last_idx
def get_solar_term_month_branch(year: int, month: int, day: int) -> int:
"""현재 날짜의 月支 인덱스 (0~11, 子=0...亥=11). 절(節) 기준 12구간.
12개 ''만 추출해, 입력 날짜 이전 가장 가까운 절의 月支를 반환.
立春 이전이면 직전 해 小寒(=丑) 또는 大雪(=子) 기준.
"""
target = _date(year, month, day)
jie_dates: list[tuple[_date, int]] = []
for y in (year - 1, year, year + 1):
for d, qi_idx in _all_jieqi_dates(y):
if qi_idx in JIEQI_TO_BRANCH:
jie_dates.append((d, JIEQI_TO_BRANCH[qi_idx]))
jie_dates.sort()
last_branch = 1 # 立春 이전 폴백 — 丑(축)
for d, branch_idx in jie_dates:
if d <= target:
last_branch = branch_idx
else:
break
return last_branch
def get_days_to_next_solar_term(year: int, month: int, day: int) -> int:
"""다음 절기까지 남은 일수 (대운 계산용).
24절기 모두 대상. 입력 날짜 이후 가장 가까운 절기까지의 일 수 반환.
"""
target = _date(year, month, day)
all_qi: list[tuple[_date, int]] = []
for y in (year, year + 1):
all_qi.extend(_all_jieqi_dates(y))
all_qi.sort()
for d, _idx in all_qi:
if d > target:
return (d - target).days
return 30 # 폴백 (이론상 도달 불가)