feat(saju-lab): solar_terms.py — sxtwl 기반 24절기 + 月支 매핑
This commit is contained in:
130
saju-lab/app/calculator/solar_terms.py
Normal file
130
saju-lab/app/calculator/solar_terms.py
Normal 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 # 폴백 (이론상 도달 불가)
|
||||
129
saju-lab/tests/test_solar_terms.py
Normal file
129
saju-lab/tests/test_solar_terms.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""solar_terms.py — sxtwl 기반 24절기 + 月支 매핑 테스트.
|
||||
|
||||
⚠️ Reference fixture(`reference_saju.json`)의 month branch는 모두 "丑"으로 동일하다.
|
||||
이는 saju-web의 `solar-terms.ts`가 tsx 런타임에서 `require('solarlunar')`를 잡지 못해
|
||||
fallback(`return 23`, 大寒)으로 떨어지고, 大寒 → 丑 매핑이 적용된 결과로 보인다.
|
||||
따라서 reference fixture는 절기 검증에 사용할 수 없으며, 본 테스트는
|
||||
- 절기 boundary (입춘 day) 동작 검증
|
||||
- sxtwl JieQi index 순서 가정 검증
|
||||
- 月支 자체 일관성(여러 날짜에서 기대되는 月支 산출) 검증
|
||||
세 가지로 구성한다.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator import solar_terms as st
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
BRANCHES = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
|
||||
|
||||
|
||||
def test_jieqi_index_order_lichun_is_3():
|
||||
"""sxtwl JieQi index에서 立春은 3(冬至=0 기준 황경 +45°).
|
||||
|
||||
1995-02-04는 입춘 당일. sxtwl이 그 날짜에 jqIndex=3을 반환해야 함.
|
||||
"""
|
||||
import sxtwl
|
||||
|
||||
qi_list = sxtwl.getJieQiByYear(1995)
|
||||
# 1995-02-04 ±1일 안에 jqIndex=3인 항목이 있어야 함
|
||||
found = False
|
||||
for info in qi_list:
|
||||
t = sxtwl.JD2DD(info.jd)
|
||||
if int(info.jqIndex) == 3 and int(t.Y) == 1995 and int(t.M) == 2 and 3 <= int(t.D) <= 5:
|
||||
found = True
|
||||
break
|
||||
assert found, "1995년 立春(jqIndex=3) 절기가 2/3~2/5 사이에서 발견되지 않음"
|
||||
|
||||
|
||||
def test_get_current_solar_term_ipchun_boundary():
|
||||
"""1995-02-04는 입춘 당일."""
|
||||
idx_4 = st.get_current_solar_term(1995, 2, 4)
|
||||
idx_5 = st.get_current_solar_term(1995, 2, 5)
|
||||
idx_3 = st.get_current_solar_term(1995, 2, 3)
|
||||
# 2/4와 2/5는 같은 절기 인덱스 (立春=3)
|
||||
assert idx_4 == idx_5
|
||||
# 2/3은 다른 인덱스 (立春 이전 — 大寒=2)
|
||||
assert idx_4 != idx_3
|
||||
|
||||
|
||||
def test_get_solar_term_month_branch_ipchun_day():
|
||||
"""1995-02-04는 입춘 → 月支 = 寅(인덱스 2)."""
|
||||
branch_idx = st.get_solar_term_month_branch(1995, 2, 4)
|
||||
assert BRANCHES[branch_idx] == "寅"
|
||||
|
||||
|
||||
def test_get_solar_term_month_branch_before_ipchun():
|
||||
"""1995-02-03은 입춘 이전 → 月支 = 丑(인덱스 1)."""
|
||||
branch_idx = st.get_solar_term_month_branch(1995, 2, 3)
|
||||
assert BRANCHES[branch_idx] == "丑"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"year,month,day,expected_branch",
|
||||
[
|
||||
# 표준 절기 경계 기준 月支 검증 (saju 일반 상식)
|
||||
(1990, 5, 15, "巳"), # 立夏(5/6) ~ 芒種(6/6) 사이 → 巳월
|
||||
(1990, 6, 6, "午"), # 芒種 당일 → 午월
|
||||
(1980, 6, 6, "午"), # 芒種 ~ 小暑 → 午월
|
||||
(1995, 2, 5, "寅"), # 立春 다음날 → 寅
|
||||
(1995, 2, 3, "丑"), # 立春 이전 → 丑
|
||||
(2000, 2, 29, "寅"), # 立春 이후 → 寅
|
||||
(2010, 12, 31, "子"), # 大雪 이후 → 子
|
||||
(1985, 1, 1, "子"), # 1/1은 小寒(1/5) 이전 → 子
|
||||
(1985, 1, 20, "丑"), # 小寒 이후 → 丑
|
||||
(1972, 7, 24, "未"), # 小暑 ~ 立秋 사이 → 未
|
||||
(1992, 8, 8, "申"), # 立秋 ~ 白露 → 申
|
||||
(1988, 9, 9, "酉"), # 白露 ~ 寒露 → 酉
|
||||
(2005, 6, 22, "午"), # 芒種 ~ 小暑 → 午
|
||||
(2020, 9, 23, "酉"), # 白露 ~ 寒露 → 酉
|
||||
],
|
||||
)
|
||||
def test_month_branch_standard_cases(year, month, day, expected_branch):
|
||||
"""일반 사주 상식 기준 月支 검증 — sxtwl 절기와 일치해야 함."""
|
||||
branch_idx = st.get_solar_term_month_branch(year, month, day)
|
||||
actual = BRANCHES[branch_idx]
|
||||
assert actual == expected_branch, (
|
||||
f"{year}-{month:02d}-{day:02d}: expected {expected_branch}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_days_to_next_solar_term_positive():
|
||||
"""다음 절기까지 일수는 항상 양수, 16일 이내."""
|
||||
# 立春(2/4) 다음날 → 雨水(2/19)까지 약 15일
|
||||
days = st.get_days_to_next_solar_term(1995, 2, 5)
|
||||
assert 1 <= days <= 16, f"days = {days} (expected 1~16)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_month_branch_matches_reference(case):
|
||||
"""Reference fixture의 month branch와 sxtwl 기반 절기 계산 일치.
|
||||
|
||||
⚠️ 알려진 이슈: reference_saju.json의 모든 30 케이스 month branch가 "丑"으로 동일하다.
|
||||
이는 saju-web `solar-terms.ts`가 tsx 런타임에서 solarlunar require 실패 →
|
||||
`return 23` 폴백 → 大寒 → 丑으로 떨어진 reference 측 버그.
|
||||
|
||||
본 테스트는 BUGGY reference와 일치하는지 검증하기 위한 것이 아니라,
|
||||
sxtwl 기반 계산이 reference와 자연스럽게 어긋남을 명시적으로 기록하기 위해 xfail로 마킹.
|
||||
"""
|
||||
inp = case["input"]
|
||||
expected_branch = case["expected"]["saju"]["month"]["branch"]
|
||||
branch_idx = st.get_solar_term_month_branch(inp["year"], inp["month"], inp["day"])
|
||||
actual_branch = BRANCHES[branch_idx]
|
||||
if expected_branch == "丑":
|
||||
pytest.xfail(
|
||||
f"reference_saju.json buggy: 모든 케이스 month='丑'으로 고정. "
|
||||
f"sxtwl 정답은 {actual_branch} (input={inp})"
|
||||
)
|
||||
assert actual_branch == expected_branch, (
|
||||
f"mismatch for {inp}: got {actual_branch}, expected {expected_branch}"
|
||||
)
|
||||
Reference in New Issue
Block a user