feat(saju-lab): daeun.py — 대운 8개 계산 (30/30 reference)
This commit is contained in:
277
saju-lab/app/calculator/daeun.py
Normal file
277
saju-lab/app/calculator/daeun.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""대운(大運) 계산 — saju-web/lib/daeun-calculator.ts 와 1:1 매핑.
|
||||
|
||||
핵심 알고리즘:
|
||||
1. 방향 결정 (양남음녀=순행, 음남양녀=역행)
|
||||
- 양/음은 년간 인덱스 % 2 == 0 여부 (TS: yearStemIndex = (year - 1900 + 6) % 10)
|
||||
2. 시작 나이 계산 (절기 기준)
|
||||
- 순행: 다음 절기까지 일수 / 3 (floor)
|
||||
- 역행: 이전 절기부터 일수 / 3 (TS는 Math.ceil 사용)
|
||||
- clamp [1, 10]
|
||||
3. 8개 대운 — 시작 나이부터 10년 단위, 월주에서 ±1씩 60갑자 진행
|
||||
|
||||
⚠️ TS `getCurrentSolarTerm`의 미묘한 동작을 재현:
|
||||
- TS는 saju-web 인덱스 i=23(대한)부터 i=0(입춘) 순서로 내려가며,
|
||||
`getSolarTermDate(year, i)` 의 날짜와 비교하여 첫 번째 매치를 반환.
|
||||
- 대한(i=23)은 1월 20일경이라 같은 해의 대부분의 날짜가 매치 → currentTerm=23이
|
||||
대다수.
|
||||
- 매치 못 찾으면 폴백 23 반환 → 입춘 이전 (1월 초·중순) 또한 23으로 처리됨.
|
||||
- 결과적으로 forward 의 days 가 매우 크게 잡혀 startAge=10으로 cap 되는 게 보통.
|
||||
- 본 구현은 sxtwl 정확 날짜로 동일한 비교 로직을 수행 → TS 결과 정확 재현.
|
||||
|
||||
⚠️ saju-web 인덱스 vs sxtwl JieQi 인덱스:
|
||||
- saju-web 0=입춘 → sxtwl 3=立春
|
||||
- 변환: sxtwl_idx = (web_idx + 3) % 24
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
HEAVENLY_STEMS,
|
||||
HEAVENLY_STEMS_KR,
|
||||
EARTHLY_BRANCHES,
|
||||
EARTHLY_BRANCHES_KR,
|
||||
)
|
||||
from .solar_terms import _all_jieqi_dates
|
||||
|
||||
|
||||
# saju-web 절기 인덱스 → sxtwl JieQi 인덱스
|
||||
# 0=입춘, 1=우수, 2=경칩, ..., 22=소한, 23=대한 (saju-web)
|
||||
# 3=立春, 4=雨水, 5=驚蟄, ..., 1=小寒, 2=大寒 (sxtwl)
|
||||
def _web_to_sxtwl_idx(web_idx: int) -> int:
|
||||
return (web_idx + 3) % 24
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _saju_web_solar_term_date(year: int, web_idx: int) -> _date:
|
||||
"""saju-web 인덱스 기준 `year` 의 절기 날짜 반환 (정확 날짜).
|
||||
|
||||
TS `getSolarTermDate(year, termIndex)` 의 1:1 대응.
|
||||
sxtwl 으로 정확한 천문학적 날짜를 사용 (TS의 search-range 근사보다 정확).
|
||||
|
||||
`year` 는 양력 그레고리안 연도. 예를 들어 大寒(saju-web 23)의 1985년 날짜는
|
||||
1985-01-20이며, sxtwl 에서는 getJieQiByYear(1984) 의 결과에 idx=2로 포함됨.
|
||||
"""
|
||||
sxtwl_idx = _web_to_sxtwl_idx(web_idx)
|
||||
# 해당 sxtwl_idx 의 절기 중 그레고리안 연도가 일치하는 날짜 검색.
|
||||
# sxtwl 의 getJieQiByYear(y) 는 立春 y년부터 그 다음 立春 직전(=大寒 y+1년)까지 포함.
|
||||
for src_year in (year - 1, year, year + 1):
|
||||
for d, idx in _all_jieqi_dates(src_year):
|
||||
if idx == sxtwl_idx and d.year == year:
|
||||
return d
|
||||
# 폴백 — 정상 동작 시 도달 불가
|
||||
base_month = [
|
||||
2, 2, 3, 3, 4, 4,
|
||||
5, 5, 6, 6, 7, 7,
|
||||
8, 8, 9, 9, 10, 10,
|
||||
11, 11, 12, 12, 1, 1,
|
||||
]
|
||||
base_day = [
|
||||
4, 19, 5, 20, 4, 20,
|
||||
5, 21, 6, 21, 7, 23,
|
||||
7, 23, 8, 23, 8, 23,
|
||||
7, 22, 7, 22, 5, 20,
|
||||
]
|
||||
return _date(year, base_month[web_idx], base_day[web_idx])
|
||||
|
||||
|
||||
def _get_current_solar_term_web(year: int, month: int, day: int) -> int:
|
||||
"""TS getCurrentSolarTerm 의 1:1 재현.
|
||||
|
||||
saju-web 절기 인덱스 (0=입춘 ... 23=대한) 중 birth date 이상인 가장 큰 i 반환.
|
||||
탐색 순서: i=23 → i=0. i=23 (대한, 1월 20일경) 매치 시 즉시 반환되므로
|
||||
대부분의 날짜가 23을 반환. 매치 못 찾으면 폴백 23.
|
||||
|
||||
TS의 특수 처리: i >= 22 (소한, 대한) 이고 birthMonth >= 2 면 termYear=year,
|
||||
그 외 i >= 22 는 termYear = year - 1. 이는 1월 출생자가 전년도 대한·소한 기준이 됨을 의미.
|
||||
본 함수는 i >= 22 일 때 TS와 동일한 termYear 조정 후 비교.
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
|
||||
for i in range(23, -1, -1):
|
||||
# 기본은 birthYear 와 동일 연도의 절기 날짜
|
||||
term_date = _saju_web_solar_term_date(year, i)
|
||||
term_year = year
|
||||
term_month = term_date.month
|
||||
term_day = term_date.day
|
||||
|
||||
# 대한(23) / 소한(22) 특수 처리 — birthMonth < 2 (1월)면 전년도 절기
|
||||
if i >= 22 and month < 2:
|
||||
term_year = year - 1
|
||||
shifted = _saju_web_solar_term_date(term_year, i)
|
||||
term_month = shifted.month
|
||||
term_day = shifted.day
|
||||
|
||||
try:
|
||||
term = _date(term_year, term_month, term_day)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if target >= term:
|
||||
return i
|
||||
|
||||
# 입춘 이전 (1월 초) — TS는 23 반환
|
||||
return 23
|
||||
|
||||
|
||||
def _calculate_daeun_start_age(
|
||||
year: int, month: int, day: int, gender: str, is_yang_year: bool
|
||||
) -> int:
|
||||
"""대운 시작 나이 정밀 계산 (절기 기준).
|
||||
|
||||
TS calculateDaeunStartAge 와 1:1 매핑.
|
||||
- 양남(陽男) / 음녀(陰女) → 순행: 다음 절기까지 일수
|
||||
- 음남(陰男) / 양녀(陽女) → 역행: 이전 절기부터 일수
|
||||
- startAge = floor(days / 3), clamp [1, 10]
|
||||
"""
|
||||
target = _date(year, month, day)
|
||||
current_term = _get_current_solar_term_web(year, month, day)
|
||||
|
||||
if (gender == "male" and is_yang_year) or (gender == "female" and not is_yang_year):
|
||||
# 순행 — 다음 절기까지
|
||||
next_term_index = (current_term + 1) % 24
|
||||
next_year = year + 1 if current_term == 23 else year
|
||||
next_date = _saju_web_solar_term_date(next_year, next_term_index)
|
||||
diff = next_date - target
|
||||
# TS: Math.ceil(diffTime / DAY) — date 차분은 정수이므로 그대로
|
||||
days = diff.days
|
||||
else:
|
||||
# 역행 — 이전 절기부터
|
||||
term_date = _saju_web_solar_term_date(year, current_term)
|
||||
term_year = year
|
||||
term_month = term_date.month
|
||||
# TS: 대한, 소한 처리 — i >= 22 일 때
|
||||
if current_term >= 22 and month >= 2:
|
||||
term_year = year
|
||||
elif current_term >= 22:
|
||||
term_year = year - 1
|
||||
term_date = _saju_web_solar_term_date(term_year, current_term)
|
||||
try:
|
||||
term_dt = _date(term_year, term_month, term_date.day)
|
||||
except ValueError:
|
||||
term_dt = term_date
|
||||
diff = target - term_dt
|
||||
days = diff.days
|
||||
|
||||
start_age = days // 3 # Math.floor
|
||||
|
||||
# 최소 1세, 최대 10세
|
||||
return max(1, min(10, start_age))
|
||||
|
||||
|
||||
def calculate_daeun(
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
gender: str,
|
||||
month_stem: str,
|
||||
month_branch: str,
|
||||
) -> list[dict]:
|
||||
"""8개 대운 계산.
|
||||
|
||||
Args:
|
||||
year, month, day: 양력 생년월일
|
||||
gender: "male" | "female"
|
||||
month_stem, month_branch: 월주 천간/지지 (사주 계산 후 전달)
|
||||
|
||||
Returns:
|
||||
8개 DaeunPillar dict (snake_case 키):
|
||||
- age: 시작 나이
|
||||
- start_year: 시작 년도
|
||||
- end_year: 끝 년도 (start_year + 9)
|
||||
- stem, branch: 천간/지지
|
||||
- stem_kr, branch_kr: 한글
|
||||
"""
|
||||
try:
|
||||
month_stem_idx = HEAVENLY_STEMS.index(month_stem)
|
||||
month_branch_idx = EARTHLY_BRANCHES.index(month_branch)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
# 양남음녀(陽男陰女)=순행, 음남양녀(陰男陽女)=역행
|
||||
# TS: yearStemIndex = (year - 1900 + 6) % 10 → isYangYear = (idx % 2 == 0)
|
||||
year_stem_idx = (year - 1900 + 6) % 10
|
||||
if year_stem_idx < 0:
|
||||
year_stem_idx += 10
|
||||
is_yang_year = (year_stem_idx % 2) == 0
|
||||
|
||||
if gender == "male":
|
||||
is_forward = is_yang_year
|
||||
else:
|
||||
is_forward = not is_yang_year
|
||||
|
||||
start_age = _calculate_daeun_start_age(year, month, day, gender, is_yang_year)
|
||||
|
||||
daeun_list: list[dict] = []
|
||||
for i in range(8):
|
||||
age = start_age + (i * 10)
|
||||
start_year = year + age
|
||||
end_year = start_year + 9
|
||||
|
||||
if is_forward:
|
||||
stem_idx = (month_stem_idx + i + 1) % 10
|
||||
branch_idx = (month_branch_idx + i + 1) % 12
|
||||
else:
|
||||
stem_idx = (month_stem_idx - i - 1 + 100) % 10
|
||||
branch_idx = (month_branch_idx - i - 1 + 120) % 12
|
||||
|
||||
daeun_list.append({
|
||||
"age": age,
|
||||
"start_year": start_year,
|
||||
"end_year": end_year,
|
||||
"stem": HEAVENLY_STEMS[stem_idx],
|
||||
"branch": EARTHLY_BRANCHES[branch_idx],
|
||||
"stem_kr": HEAVENLY_STEMS_KR[stem_idx],
|
||||
"branch_kr": EARTHLY_BRANCHES_KR[branch_idx],
|
||||
})
|
||||
|
||||
return daeun_list
|
||||
|
||||
|
||||
def get_current_daeun(daeun_list: list[dict], current_year: int) -> Optional[dict]:
|
||||
"""현재 연도에 해당하는 대운 반환 (없으면 None)."""
|
||||
for d in daeun_list:
|
||||
if d["start_year"] <= current_year <= d["end_year"]:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def get_daeun_description(daeun: dict, day_stem: str) -> str:
|
||||
"""대운 해석 — TS getDaeunDescription 와 1:1 매핑.
|
||||
|
||||
daeun dict 키는 snake_case (age, stem, branch, stem_kr, branch_kr).
|
||||
"""
|
||||
age = daeun["age"]
|
||||
stem = daeun["stem"]
|
||||
branch = daeun["branch"]
|
||||
stem_kr = daeun["stem_kr"]
|
||||
branch_kr = daeun["branch_kr"]
|
||||
ganzi = f"{stem}{branch}"
|
||||
|
||||
description = (
|
||||
f"{age}세부터 {age + 9}세까지의 10년은 {stem_kr}{branch_kr}({ganzi}) 대운입니다. "
|
||||
)
|
||||
|
||||
# 대운 천간 인덱스 (음양 판정용)
|
||||
try:
|
||||
stem_idx = HEAVENLY_STEMS.index(stem)
|
||||
except ValueError:
|
||||
stem_idx = 0
|
||||
|
||||
if age < 20:
|
||||
description += "청소년기로 학업과 기초를 다지는 시기입니다. "
|
||||
elif age < 40:
|
||||
description += "성장과 발전의 시기로 사회활동이 왕성한 때입니다. "
|
||||
elif age < 60:
|
||||
description += "안정과 성숙의 시기로 경험이 쌓이는 때입니다. "
|
||||
else:
|
||||
description += "원숙한 시기로 인생의 지혜를 나누는 때입니다. "
|
||||
|
||||
if stem_idx % 2 == 0:
|
||||
description += "적극적이고 외향적인 활동이 유리합니다."
|
||||
else:
|
||||
description += "차분하고 내실을 다지는 것이 좋습니다."
|
||||
|
||||
return description
|
||||
107
saju-lab/tests/test_daeun.py
Normal file
107
saju-lab/tests/test_daeun.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""daeun.py — 대운 8개 계산 검증.
|
||||
|
||||
fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator.core import calculate_saju
|
||||
from app.calculator.daeun import calculate_daeun, get_current_daeun, get_daeun_description
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch.isupper():
|
||||
out.append("_" + ch.lower())
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize(d):
|
||||
"""TS camelCase → Python snake_case (deep)."""
|
||||
if isinstance(d, dict):
|
||||
return {_camel_to_snake(k): _normalize(v) for k, v in d.items()}
|
||||
if isinstance(d, list):
|
||||
return [_normalize(x) for x in d]
|
||||
return d
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}-{c['input']['gender']}",
|
||||
)
|
||||
def test_calculate_daeun_matches_reference(case):
|
||||
inp = case["input"]
|
||||
expected = [_normalize(x) for x in case["expected"]["daeun"]]
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = calculate_daeun(
|
||||
inp["year"],
|
||||
inp["month"],
|
||||
inp["day"],
|
||||
inp["gender"],
|
||||
saju["month"]["stem"],
|
||||
saju["month"]["branch"],
|
||||
)
|
||||
|
||||
assert len(actual) == len(expected), (
|
||||
f"len mismatch for {inp}: {len(actual)} vs {len(expected)}"
|
||||
)
|
||||
|
||||
for i, (a, e) in enumerate(zip(actual, expected)):
|
||||
for key in ("stem", "branch", "stem_kr", "branch_kr"):
|
||||
assert a.get(key) == e.get(key), (
|
||||
f"{inp} daeun[{i}].{key}: actual={a.get(key)} expected={e.get(key)}"
|
||||
)
|
||||
# age + start_year/end_year
|
||||
assert a.get("age") == e.get("age"), (
|
||||
f"{inp} daeun[{i}].age: {a.get('age')} vs {e.get('age')}"
|
||||
)
|
||||
assert a.get("start_year") == e.get("start_year"), (
|
||||
f"{inp} daeun[{i}].start_year: {a.get('start_year')} vs {e.get('start_year')}"
|
||||
)
|
||||
assert a.get("end_year") == e.get("end_year"), (
|
||||
f"{inp} daeun[{i}].end_year: {a.get('end_year')} vs {e.get('end_year')}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_current_daeun_returns_match():
|
||||
daeun_list = [
|
||||
{"age": 10, "start_year": 2000, "end_year": 2009},
|
||||
{"age": 20, "start_year": 2010, "end_year": 2019},
|
||||
{"age": 30, "start_year": 2020, "end_year": 2029},
|
||||
]
|
||||
assert get_current_daeun(daeun_list, 2005) == daeun_list[0]
|
||||
assert get_current_daeun(daeun_list, 2015) == daeun_list[1]
|
||||
assert get_current_daeun(daeun_list, 2025) == daeun_list[2]
|
||||
assert get_current_daeun(daeun_list, 1999) is None
|
||||
assert get_current_daeun(daeun_list, 2030) is None
|
||||
|
||||
|
||||
def test_get_daeun_description_returns_string():
|
||||
daeun = {
|
||||
"age": 10,
|
||||
"start_year": 2000,
|
||||
"end_year": 2009,
|
||||
"stem": "壬",
|
||||
"branch": "午",
|
||||
"stem_kr": "임",
|
||||
"branch_kr": "오",
|
||||
}
|
||||
desc = get_daeun_description(daeun, "辛")
|
||||
assert isinstance(desc, str)
|
||||
assert "임오" in desc
|
||||
assert "壬午" in desc
|
||||
assert "10세" in desc
|
||||
assert "19세" in desc
|
||||
Reference in New Issue
Block a user