"""대운(大運) 계산 — 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