"""신살 + 지장간 + 공망 + 지지 상호작용. saju-web/lib/saju-calculator.ts 의 다음 함수와 1:1 매핑: - getHiddenStems / getAllHiddenStems (지장간) - analyzeBranchInteractions (육합/삼합/방합/충/형/자형/파/해) - calculateShinsal (역마/도화/화개/천을귀인/문창귀인/천덕귀인) - calculateGongmang (공망) 입력 saju dict는 core.calculate_saju 의 snake_case 출력 (year/month/day/hour pillar 각각 stem, branch, stem_kr, branch_kr, element, ten_god, fortune. day_stem 별도). 출력 키는 snake_case (branch_kr, branches_kr, result_element 등). 단, `pillar` 값은 TS와 동일하게 한글(`년주`/`월주`/`일주`/`시주`)을 사용. """ from __future__ import annotations from typing import Optional from .constants import ( HEAVENLY_STEMS, HEAVENLY_STEMS_KR, EARTHLY_BRANCHES, EARTHLY_BRANCHES_KR, FIVE_ELEMENTS, HIDDEN_STEMS, ) # ─── 지장간 ──────────────────────────────────────────────────────── def get_hidden_stems(branch: str) -> list[str]: """지지의 지장간(숨은 천간) — [본기, 중기, 여기].""" return list(HIDDEN_STEMS.get(branch, [])) def get_all_hidden_stems(saju: dict) -> list[dict]: """4주 전체의 지장간 정보 반환. 출력 구조: [{ "pillar": "년주", "branch": "午", "branch_kr": "오", "stems": [ {"stem": "丁", "stem_kr": "정", "element": "火", "role": "정기(본기)"}, {"stem": "己", "stem_kr": "기", "element": "土", "role": "중기"}, ], }, ...] """ pillars: list[dict] = [ {"pillar": "년주", "branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"]}, {"pillar": "월주", "branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"]}, {"pillar": "일주", "branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"]}, ] if saju.get("hour") is not None: pillars.append({ "pillar": "시주", "branch": saju["hour"]["branch"], "branch_kr": saju["hour"]["branch_kr"], }) result: list[dict] = [] for p in pillars: hidden = get_hidden_stems(p["branch"]) stems = [] for idx, stem in enumerate(hidden): stem_index = HEAVENLY_STEMS.index(stem) if idx == 0: role = "정기(본기)" elif idx == 1: role = "중기" else: role = "여기" stems.append({ "stem": stem, "stem_kr": HEAVENLY_STEMS_KR[stem_index], "element": FIVE_ELEMENTS[stem], "role": role, }) result.append({ "pillar": p["pillar"], "branch": p["branch"], "branch_kr": p["branch_kr"], "stems": stems, }) return result # ─── 지지 상호작용 ────────────────────────────────────────────────── # 육합 (六合): 子丑(土), 寅亥(木), 卯戌(火), 辰酉(金), 巳申(水), 午未(火) _YUKAP_PAIRS: list[tuple[str, str, str]] = [ ("子", "丑", "土"), ("寅", "亥", "木"), ("卯", "戌", "火"), ("辰", "酉", "金"), ("巳", "申", "水"), ("午", "未", "火"), ] # 삼합 (三合) _SAMHAP_GROUPS: list[tuple[str, str, str, str]] = [ ("申", "子", "辰", "水"), ("亥", "卯", "未", "木"), ("寅", "午", "戌", "火"), ("巳", "酉", "丑", "金"), ] # 방합 (方合) _BANGHAP_GROUPS: list[tuple[str, str, str, str]] = [ ("寅", "卯", "辰", "木"), ("巳", "午", "未", "火"), ("申", "酉", "戌", "金"), ("亥", "子", "丑", "水"), ] # 충 (沖) _CHUNG_PAIRS: list[tuple[str, str]] = [ ("子", "午"), ("丑", "未"), ("寅", "申"), ("卯", "酉"), ("辰", "戌"), ("巳", "亥"), ] # 형 (刑) _HYUNG_GROUPS: list[dict] = [ {"branches": ["寅", "巳", "申"], "name": "무은지형(無恩之刑)"}, {"branches": ["丑", "戌", "未"], "name": "지세지형(恃勢之刑)"}, {"branches": ["子", "卯"], "name": "무례지형(無禮之刑)"}, ] _JAHYUNG_BRANCHES = ["辰", "午", "酉", "亥"] # 파 (破) _PA_PAIRS: list[tuple[str, str]] = [ ("子", "酉"), ("丑", "辰"), ("寅", "亥"), ("卯", "午"), ("巳", "申"), ("未", "戌"), ] # 해 (害) _HAE_PAIRS: list[tuple[str, str]] = [ ("子", "未"), ("丑", "午"), ("寅", "巳"), ("卯", "辰"), ("申", "亥"), ("酉", "戌"), ] _ELEMENT_NAMES_KR: dict[str, str] = { "木": "목", "火": "화", "土": "토", "金": "금", "水": "수", } def _collect_pillar_branches(saju: dict) -> list[dict]: """4주 지지 리스트 — TS analyzeBranchInteractions 내부 변수와 동일 순서.""" out: list[dict] = [ {"branch": saju["year"]["branch"], "pillar": "년주", "branch_kr": saju["year"]["branch_kr"]}, {"branch": saju["month"]["branch"], "pillar": "월주", "branch_kr": saju["month"]["branch_kr"]}, {"branch": saju["day"]["branch"], "pillar": "일주", "branch_kr": saju["day"]["branch_kr"]}, ] if saju.get("hour") is not None: out.append({ "branch": saju["hour"]["branch"], "pillar": "시주", "branch_kr": saju["hour"]["branch_kr"], }) return out def analyze_branch_interactions(saju: dict) -> list[dict]: """지지 상호작용 분석 (육합/삼합/반삼합/방합/충/형/자형/파/해). TS analyzeBranchInteractions 와 1:1 동일. branches는 첫 번째 발견 인덱스만 본다 (중복 지지가 있을 때 TS의 indexOf 동작과 동일). """ interactions: list[dict] = [] pillar_branches = _collect_pillar_branches(saju) branches = [pb["branch"] for pb in pillar_branches] # 육합 (六合) for a, b, elem in _YUKAP_PAIRS: if a in branches and b in branches: ia = branches.index(a) ib = branches.index(b) interactions.append({ "type": "육합(六合)", "branches": [a, b], "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], "description": ( f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 육합 " f"→ {_ELEMENT_NAMES_KR[elem]}({elem}) 기운 생성. 조화와 화합의 관계." ), "result_element": elem, }) # 삼합 (三合) / 반삼합 (半三合) for a, b, c, elem in _SAMHAP_GROUPS: found = [x for x in (a, b, c) if x in branches] if len(found) >= 2: found_pillars = [pillar_branches[branches.index(x)] for x in found] is_complete = len(found) == 3 type_kr = "삼합(三合)" if is_complete else "반삼합(半三合)" kr_str = "".join(p["branch_kr"] for p in found_pillars) samhap_label = "삼합" if is_complete else "반삼합" tail = "강력한 합의 기운." if is_complete else "삼합의 기운이 부분적으로 작용." interactions.append({ "type": type_kr, "branches": found, "branches_kr": [p["branch_kr"] for p in found_pillars], "pillars": [p["pillar"] for p in found_pillars], "description": ( f"{kr_str} {samhap_label} → {_ELEMENT_NAMES_KR[elem]}({elem})국. {tail}" ), "result_element": elem, }) # 방합 (方合) for a, b, c, elem in _BANGHAP_GROUPS: found = [x for x in (a, b, c) if x in branches] if len(found) == 3: found_pillars = [pillar_branches[branches.index(x)] for x in found] kr_str = "".join(p["branch_kr"] for p in found_pillars) interactions.append({ "type": "방합(方合)", "branches": found, "branches_kr": [p["branch_kr"] for p in found_pillars], "pillars": [p["pillar"] for p in found_pillars], "description": ( f"{kr_str} 방합 → {_ELEMENT_NAMES_KR[elem]}({elem}) 방국. 매우 강한 오행 기운." ), "result_element": elem, }) # 충 (沖) for a, b in _CHUNG_PAIRS: if a in branches and b in branches: ia = branches.index(a) ib = branches.index(b) interactions.append({ "type": "충(沖)", "branches": [a, b], "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], "description": ( f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 충 " f"→ 변동, 갈등, 변화의 에너지. " f"{pillar_branches[ia]['pillar']}와 {pillar_branches[ib]['pillar']} 사이의 긴장 관계." ), }) # 형 (刑) for group in _HYUNG_GROUPS: found = [x for x in group["branches"] if x in branches] if len(found) >= 2: found_pillars = [pillar_branches[branches.index(x)] for x in found] kr_str = "".join(p["branch_kr"] for p in found_pillars) interactions.append({ "type": "형(刑)", "branches": found, "branches_kr": [p["branch_kr"] for p in found_pillars], "pillars": [p["pillar"] for p in found_pillars], "description": f"{kr_str} {group['name']} → 시련과 갈등의 기운. 주의가 필요한 관계.", }) # 자형 (自刑) for jb in _JAHYUNG_BRANCHES: count = branches.count(jb) if count >= 2: br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(jb)] interactions.append({ "type": "자형(自刑)", "branches": [jb, jb], "branches_kr": [br_kr, br_kr], "pillars": [p["pillar"] for p in pillar_branches if p["branch"] == jb], "description": f"{br_kr}{br_kr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.", }) # 파 (破) for a, b in _PA_PAIRS: if a in branches and b in branches: ia = branches.index(a) ib = branches.index(b) interactions.append({ "type": "파(破)", "branches": [a, b], "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], "description": ( f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 파 " f"→ 관계의 균열, 계획의 차질 가능성." ), }) # 해 (害) for a, b in _HAE_PAIRS: if a in branches and b in branches: ia = branches.index(a) ib = branches.index(b) interactions.append({ "type": "해(害)", "branches": [a, b], "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], "description": ( f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 해 " f"→ 은근한 방해, 원망의 기운." ), }) return interactions # ─── 신살 ────────────────────────────────────────────────────────── # 일지 삼합국 기준 신살 매핑 _SAMHAP_GROUP_MAP: dict[str, str] = { "申": "申子辰", "子": "申子辰", "辰": "申子辰", "寅": "寅午戌", "午": "寅午戌", "戌": "寅午戌", "巳": "巳酉丑", "酉": "巳酉丑", "丑": "巳酉丑", "亥": "亥卯未", "卯": "亥卯未", "未": "亥卯未", } _YEOKMA_MAP: dict[str, str] = { "申子辰": "寅", "寅午戌": "申", "巳酉丑": "亥", "亥卯未": "巳", } _DOHWA_MAP: dict[str, str] = { "申子辰": "酉", "寅午戌": "卯", "巳酉丑": "午", "亥卯未": "子", } _HWAGAE_MAP: dict[str, str] = { "申子辰": "辰", "寅午戌": "戌", "巳酉丑": "丑", "亥卯未": "未", } # 천을귀인 (天乙貴人) — 일간 기준 _CHEONUL_MAP: dict[str, list[str]] = { "甲": ["丑", "未"], "乙": ["子", "申"], "丙": ["亥", "酉"], "丁": ["亥", "酉"], "戊": ["丑", "未"], "己": ["子", "申"], "庚": ["丑", "未"], "辛": ["寅", "午"], "壬": ["卯", "巳"], "癸": ["卯", "巳"], } # 문창귀인 (文昌貴人) — 일간 기준 _MUNCHANG_MAP: dict[str, str] = { "甲": "巳", "乙": "午", "丙": "申", "丁": "酉", "戊": "申", "己": "酉", "庚": "亥", "辛": "子", "壬": "寅", "癸": "卯", } # 천덕귀인 (天德貴人) — 월지 기준 천간 _CHEONDUK_MAP: dict[str, str] = { "寅": "丁", "卯": "申", "辰": "壬", "巳": "辛", "午": "亥", "未": "甲", "申": "癸", "酉": "寅", "戌": "丙", "亥": "乙", "子": "巳", "丑": "庚", } def calculate_shinsal(saju: dict) -> list[dict]: """신살 계산 (역마/도화/화개/천을귀인/문창귀인/천덕귀인). TS calculateShinsal 와 1:1 동일. """ result: list[dict] = [] day_branch = saju["day"]["branch"] day_stem = saju["day_stem"] month_branch = saju["month"]["branch"] pillar_branches: list[dict] = [ {"branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"], "pillar": "년주"}, {"branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"], "pillar": "월주"}, {"branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"], "pillar": "일주"}, ] if saju.get("hour") is not None: pillar_branches.append({ "branch": saju["hour"]["branch"], "branch_kr": saju["hour"]["branch_kr"], "pillar": "시주", }) group = _SAMHAP_GROUP_MAP.get(day_branch) if group: # 역마살 yeokma = _YEOKMA_MAP[group] for pb in pillar_branches: if pb["branch"] == yeokma and pb["pillar"] != "일주": result.append({ "name": "역마살", "name_hanja": "驛馬殺", "branch": yeokma, "branch_kr": pb["branch_kr"], "pillar": pb["pillar"], "description": "이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.", }) # 도화살 dohwa = _DOHWA_MAP[group] for pb in pillar_branches: if pb["branch"] == dohwa and pb["pillar"] != "일주": result.append({ "name": "도화살", "name_hanja": "桃花殺", "branch": dohwa, "branch_kr": pb["branch_kr"], "pillar": pb["pillar"], "description": "매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.", }) # 화개살 hwagae = _HWAGAE_MAP[group] for pb in pillar_branches: if pb["branch"] == hwagae and pb["pillar"] != "일주": result.append({ "name": "화개살", "name_hanja": "華蓋殺", "branch": hwagae, "branch_kr": pb["branch_kr"], "pillar": pb["pillar"], "description": "학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.", }) # 천을귀인 — 일간 기준 cheonul_branches = _CHEONUL_MAP.get(day_stem, []) for pb in pillar_branches: if pb["branch"] in cheonul_branches and pb["pillar"] != "일주": result.append({ "name": "천을귀인", "name_hanja": "天乙貴人", "branch": pb["branch"], "branch_kr": pb["branch_kr"], "pillar": pb["pillar"], "description": "위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.", }) # 문창귀인 — 일간 기준 munchang_branch = _MUNCHANG_MAP.get(day_stem) if munchang_branch: for pb in pillar_branches: if pb["branch"] == munchang_branch and pb["pillar"] != "일주": result.append({ "name": "문창귀인", "name_hanja": "文昌貴人", "branch": pb["branch"], "branch_kr": pb["branch_kr"], "pillar": pb["pillar"], "description": "학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.", }) # 천덕귀인 — 월지 기준 천간이 4주 천간에 있으면 cheonduk_stem = _CHEONDUK_MAP.get(month_branch) if cheonduk_stem: all_stems: list[dict] = [ {"stem": saju["year"]["stem"], "pillar": "년주"}, {"stem": saju["day"]["stem"], "pillar": "일주"}, ] if saju.get("hour") is not None: all_stems.append({"stem": saju["hour"]["stem"], "pillar": "시주"}) for ps in all_stems: if ps["stem"] == cheonduk_stem: br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(month_branch)] result.append({ "name": "천덕귀인", "name_hanja": "天德貴人", "branch": month_branch, "branch_kr": br_kr, "pillar": ps["pillar"], "description": "하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.", }) return result # ─── 공망 ────────────────────────────────────────────────────────── def calculate_gongmang(day_stem: str, day_branch: str) -> dict: """공망(空亡) — 일주의 旬(60갑자 10단위)에서 빠지는 2개 지지. TS calculateGongmang 와 1:1 동일. """ stem_idx = HEAVENLY_STEMS.index(day_stem) branch_idx = EARTHLY_BRANCHES.index(day_branch) # 60갑자에서 해당 旬의 시작점 = 천간이 甲인 지점 # 旬의 시작 지지 인덱스 = (branchIdx - stemIdx + 120) % 12 start_branch_idx = (branch_idx - stem_idx + 120) % 12 # 공망 = 旬 시작 + 10, +11 gongmang1_idx = (start_branch_idx + 10) % 12 gongmang2_idx = (start_branch_idx + 11) % 12 branch1 = EARTHLY_BRANCHES[gongmang1_idx] branch2 = EARTHLY_BRANCHES[gongmang2_idx] branch_kr1 = EARTHLY_BRANCHES_KR[gongmang1_idx] branch_kr2 = EARTHLY_BRANCHES_KR[gongmang2_idx] return { "branches": [branch1, branch2], "branches_kr": [branch_kr1, branch_kr2], "description": ( f"{branch_kr1}({branch1})·{branch_kr2}({branch2}) 공망 " f"→ 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. " f"오히려 초월적 능력이 될 수 있음." ), }