feat(saju-lab): daeun.py — 대운 8개 계산 (30/30 reference)

This commit is contained in:
2026-05-25 20:09:46 +09:00
parent ebfade655a
commit db1f69c7a5
2 changed files with 384 additions and 0 deletions

View 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