"""사주 핵심 계산 — 60갑자 + 십성 + 십이운성 + calculate_saju. saju-web/lib/saju-calculator.ts 의 알고리즘과 1:1 매핑. 핵심 결정 사항: - 년주: 1900-01-01 = 庚子년(stem=6, branch=0) 기준 단순 차분 (TS 동일) - 월주: 月支 = 절기 기반 (sxtwl), 月干 = (yearStemIndex * 2 + branchIndex) % 10 (TS 동일) - 일주: 1900-01-01 = 丙寅일(stem=2, branch=2) 기준 그레고리안 일수 차분 (TS 동일, sxtwl 미사용) - 시주: branchIndex = hour-to-branch 매핑, stemIndex = (dayStemIndex * 2 + branchIndex) % 10 - 십성: 일간/대상 오행 관계 + isYang 플래그 (호출자가 음양 동일 여부 결정) - 십이운성: 일간×지지 12 갑자 하드코딩 매핑 (건록, '임관' 아님) """ from __future__ import annotations from datetime import date as _date from typing import Optional from .constants import ( HEAVENLY_STEMS, HEAVENLY_STEMS_KR, EARTHLY_BRANCHES, EARTHLY_BRANCHES_KR, FIVE_ELEMENTS, ) from .solar_terms import get_solar_term_month_branch # ─── 기준일 (TS saju-calculator.ts와 동일) ──────────────────────────── BASE_YEAR = 1900 BASE_YEAR_STEM = 6 # 庚 BASE_YEAR_BRANCH = 0 # 子 BASE_DAY_STEM = 2 # 丙 BASE_DAY_BRANCH = 2 # 寅 _BASE_DATE = _date(1900, 1, 1) # ─── 십이운성 ───────────────────────────────────────────────────── TWELVE_FORTUNES = [ "장생", "목욕", "관대", "건록", "제왕", "쇠", "병", "사", "묘", "절", "태", "양", ] # 일간 × 지지 → TWELVE_FORTUNES 인덱스 # saju-web TS getTwelveFortune() 함수의 hardcoded map과 1:1 일치 _FORTUNE_MAP: dict[str, dict[str, int]] = { "甲": {"亥": 11, "子": 0, "丑": 1, "寅": 2, "卯": 3, "辰": 4, "巳": 5, "午": 6, "未": 7, "申": 8, "酉": 9, "戌": 10}, "乙": {"午": 11, "未": 0, "申": 1, "酉": 2, "戌": 3, "亥": 4, "子": 5, "丑": 6, "寅": 7, "卯": 8, "辰": 9, "巳": 10}, "丙": {"寅": 11, "卯": 0, "辰": 1, "巳": 2, "午": 3, "未": 4, "申": 5, "酉": 6, "戌": 7, "亥": 8, "子": 9, "丑": 10}, "丁": {"酉": 11, "戌": 0, "亥": 1, "子": 2, "丑": 3, "寅": 4, "卯": 5, "辰": 6, "巳": 7, "午": 8, "未": 9, "申": 10}, "戊": {"寅": 11, "卯": 0, "辰": 1, "巳": 2, "午": 3, "未": 4, "申": 5, "酉": 6, "戌": 7, "亥": 8, "子": 9, "丑": 10}, "己": {"酉": 11, "戌": 0, "亥": 1, "子": 2, "丑": 3, "寅": 4, "卯": 5, "辰": 6, "巳": 7, "午": 8, "未": 9, "申": 10}, "庚": {"巳": 11, "午": 0, "未": 1, "申": 2, "酉": 3, "戌": 4, "亥": 5, "子": 6, "丑": 7, "寅": 8, "卯": 9, "辰": 10}, "辛": {"子": 11, "丑": 0, "寅": 1, "卯": 2, "辰": 3, "巳": 4, "午": 5, "未": 6, "申": 7, "酉": 8, "戌": 9, "亥": 10}, "壬": {"申": 11, "酉": 0, "戌": 1, "亥": 2, "子": 3, "丑": 4, "寅": 5, "卯": 6, "辰": 7, "巳": 8, "午": 9, "未": 10}, "癸": {"卯": 11, "辰": 0, "巳": 1, "午": 2, "未": 3, "申": 4, "酉": 5, "戌": 6, "亥": 7, "子": 8, "丑": 9, "寅": 10}, } # ─── 오행 상생/상극 (TS getTenGod 내부 매핑과 동일) ─────────────────── _PRODUCE_MAP = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"} _OVERCOME_MAP = {"木": "土", "火": "金", "土": "水", "金": "木", "水": "火"} # ─── 헬퍼 ───────────────────────────────────────────────────────── def _make_pillar(stem_idx: int, branch_idx: int) -> dict: """천간/지지 인덱스 → pillar 기본 필드 (element 포함).""" stem = HEAVENLY_STEMS[stem_idx] branch = EARTHLY_BRANCHES[branch_idx] return { "stem": stem, "branch": branch, "stem_kr": HEAVENLY_STEMS_KR[stem_idx], "branch_kr": EARTHLY_BRANCHES_KR[branch_idx], "element": FIVE_ELEMENTS[stem], } # ─── 60갑자 ─────────────────────────────────────────────────────── def get_year_ganzi(year: int) -> dict: """년주 — 1900년 庚子 기준 단순 차분 (TS getYearGanzi 동일).""" year_diff = year - BASE_YEAR stem_idx = (BASE_YEAR_STEM + year_diff) % 10 branch_idx = (BASE_YEAR_BRANCH + year_diff) % 12 # 음수 보정 if stem_idx < 0: stem_idx += 10 if branch_idx < 0: branch_idx += 12 return _make_pillar(stem_idx, branch_idx) def get_month_ganzi(year: int, month: int, day: int) -> dict: """월주 — 月支 = 절기 기반, 月干 = (yearStemIdx*2 + branchIdx) % 10. TS getMonthGanzi와 동일. """ branch_idx = get_solar_term_month_branch(year, month, day) year_stem = get_year_ganzi(year)["stem"] year_stem_idx = HEAVENLY_STEMS.index(year_stem) stem_idx = (year_stem_idx * 2 + branch_idx) % 10 return _make_pillar(stem_idx, branch_idx) def get_day_ganzi(year: int, month: int, day: int) -> dict: """일주 — 1900-01-01(丙寅) 기준 그레고리안 일수 차분 (TS getDayGanzi 동일). sxtwl을 사용하지 않음 — TS와 1:1 일치를 위해 단순 일수 차분. ⚠️ TS는 JavaScript `new Date(1900, 0, 1).getTime()`을 사용하는데, Asia/Seoul timezone에서 1900-01-01 자정은 LMT(+8:27:52) 보정 때문에 UTC 기준으로 1899-12-31T15:32:08Z에 해당. 반면 후대 날짜는 KST(+9:00) 기준이라 Math.floor((target - base) / DAY)는 정확한 일수보다 1 작게 계산됨 (모든 케이스가 균일하게 -1). 본 함수는 그 결과를 재현하기 위해 days_diff -= 1. """ target = _date(year, month, day) days_diff = (target - _BASE_DATE).days - 1 # TS Asia/Seoul LMT artifact 보정 stem_idx = (BASE_DAY_STEM + days_diff) % 10 branch_idx = (BASE_DAY_BRANCH + days_diff) % 12 if stem_idx < 0: stem_idx += 10 if branch_idx < 0: branch_idx += 12 return _make_pillar(stem_idx, branch_idx) def _hour_to_branch_idx(hour: int) -> int: """시간 → 시지 인덱스 (TS getHourGanzi 내부 분기 동일). 23~01 = 子(0), 01~03 = 丑(1), 03~05 = 寅(2), ..., 21~23 = 亥(11) """ if hour >= 23 or hour < 1: return 0 if 1 <= hour < 3: return 1 if 3 <= hour < 5: return 2 if 5 <= hour < 7: return 3 if 7 <= hour < 9: return 4 if 9 <= hour < 11: return 5 if 11 <= hour < 13: return 6 if 13 <= hour < 15: return 7 if 15 <= hour < 17: return 8 if 17 <= hour < 19: return 9 if 19 <= hour < 21: return 10 return 11 # 21~23 def get_hour_ganzi(day_stem: str, hour: int) -> dict: """시주 — branchIdx = hour-to-branch, stemIdx = (dayStemIdx*2 + branchIdx) % 10. TS getHourGanzi와 동일 (五鼠遁訣 정통 공식이 아닌 단순 *2 패턴). """ branch_idx = _hour_to_branch_idx(hour) day_stem_idx = HEAVENLY_STEMS.index(day_stem) stem_idx = (day_stem_idx * 2 + branch_idx) % 10 return _make_pillar(stem_idx, branch_idx) # ─── 십성 ───────────────────────────────────────────────────────── def get_ten_god(day_stem: str, target_stem: str, is_yang: bool) -> str: """십성 — 일간/대상 오행 관계 + is_yang (TS getTenGod 동일). is_yang은 호출자가 '대상이 일간과 동일한 음양인가?'로 미리 계산해서 전달. 같은 음양 → 비견/식신/편재/편관/편인 다른 음양 → 겁재/상관/정재/정관/정인 """ day_element = FIVE_ELEMENTS[day_stem] target_element = FIVE_ELEMENTS[target_stem] if day_element == target_element: return "비견" if is_yang else "겁재" if _PRODUCE_MAP[day_element] == target_element: return "식신" if is_yang else "상관" if _OVERCOME_MAP[day_element] == target_element: return "편재" if is_yang else "정재" if _OVERCOME_MAP[target_element] == day_element: return "편관" if is_yang else "정관" if _PRODUCE_MAP[target_element] == day_element: return "편인" if is_yang else "정인" return "비견" # ─── 십이운성 ───────────────────────────────────────────────────── def get_twelve_fortune(day_stem: str, branch: str) -> str: """십이운성 — 일간×지지 하드코딩 매핑 (TS getTwelveFortune 동일).""" idx = _FORTUNE_MAP.get(day_stem, {}).get(branch, 0) return TWELVE_FORTUNES[idx] # ─── 사주 통합 계산 ──────────────────────────────────────────────── def calculate_saju( year: int, month: int, day: int, hour: Optional[int], gender: str, ) -> dict: """사주팔자 전체 계산 — TS calculateSaju와 1:1 일치. 반환 키는 snake_case (year, month, day, hour, day_stem, birth_date, gender). """ year_pillar = get_year_ganzi(year) month_pillar = get_month_ganzi(year, month, day) day_pillar = get_day_ganzi(year, month, day) day_stem = day_pillar["stem"] day_stem_idx = HEAVENLY_STEMS.index(day_stem) is_day_yang = (day_stem_idx % 2) == 0 def _ten_god_for(stem: str) -> str: target_idx = HEAVENLY_STEMS.index(stem) target_yang = (target_idx % 2) == 0 # is_yang 인자는 '대상이 일간과 같은 음양인가?' return get_ten_god(day_stem, stem, target_yang == is_day_yang) # 년주 — ten_god + fortune 부착 year_pillar["ten_god"] = _ten_god_for(year_pillar["stem"]) year_pillar["fortune"] = get_twelve_fortune(day_stem, year_pillar["branch"]) # 월주 month_pillar["ten_god"] = _ten_god_for(month_pillar["stem"]) month_pillar["fortune"] = get_twelve_fortune(day_stem, month_pillar["branch"]) # 일주 — ten_god='일간' day_pillar["ten_god"] = "일간" day_pillar["fortune"] = get_twelve_fortune(day_stem, day_pillar["branch"]) birth_date: dict = {"year": year, "month": month, "day": day} result: dict = { "year": year_pillar, "month": month_pillar, "day": day_pillar, "day_stem": day_stem, "birth_date": birth_date, "gender": gender, } # 시주 if hour is not None: hour_pillar = get_hour_ganzi(day_stem, hour) hour_pillar["ten_god"] = _ten_god_for(hour_pillar["stem"]) hour_pillar["fortune"] = get_twelve_fortune(day_stem, hour_pillar["branch"]) result["hour"] = hour_pillar birth_date["hour"] = hour return result