278 lines
9.9 KiB
Python
278 lines
9.9 KiB
Python
"""대운(大運) 계산 — 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
|