"""사주 종합 분석 — 오행/신강신약/용신/세운/perform_full_analysis. saju-web/lib/ai-interpretation.ts 와 1:1 매핑. 핵심 함수: - calculate_detailed_element_balance: 가중치 오행 점수 (천간 1.0, 본기 1.0, 중기 0.5, 여기 0.3) - calculate_element_score: 비율 (%) - analyze_day_master_strength: 신강/신약/중화 + score(정수) + reasons(텍스트 리스트) - estimate_yongshin: 용신/희신/기신 + explanation - calculate_seun: 올해(주어진 year) 세운 + 일주/년월시 충/합 매핑 - perform_full_analysis: 위 모두 + branch_interactions + shinsal + gongmang + hidden_stems 출력 키는 snake_case. pillar 값은 한글(`세운`/`년주`/`월주`/`일주`/`시주`). """ from __future__ import annotations import math from typing import Optional from .constants import ( FIVE_ELEMENTS, FIVE_ELEMENTS_KR, HIDDEN_STEMS, ) from .shinsal import ( get_hidden_stems, get_all_hidden_stems, analyze_branch_interactions, calculate_shinsal, calculate_gongmang, ) from .core import get_year_ganzi # ─── 상생/상극 맵 (TS PRODUCE_MAP / OVERCOME_MAP 동일) ───────────── _PRODUCE_MAP: dict[str, str] = { "木": "火", "火": "土", "土": "金", "金": "水", "水": "木", } _OVERCOME_MAP: dict[str, str] = { "木": "土", "火": "金", "土": "水", "金": "木", "水": "火", } def _get_producing_element(elem: str) -> str: """`elem`을 생하는 오행 (= 인성). TS getProducingElement 동일.""" for k, v in _PRODUCE_MAP.items(): if v == elem: return k return "" def _get_overcoming_me(elem: str) -> str: """`elem`을 극하는 오행 (= 관살). TS getOvercomingMe 동일.""" for k, v in _OVERCOME_MAP.items(): if v == elem: return k return "" def _js_round(x: float) -> int: """JavaScript Math.round 호환 — half-away-from-zero (양수에서는 half-up). Python의 round()는 banker's rounding(half-to-even)을 쓰므로 호환을 위해 우회. 오행 점수는 항상 ≥ 0 이므로 floor(x + 0.5) 로 충분. """ if x >= 0: return math.floor(x + 0.5) return -math.floor(-x + 0.5) # ─── 오행 밸런스 ────────────────────────────────────────────────── def calculate_detailed_element_balance(saju: dict) -> dict: """가중치 적용 오행 점수. - 천간 stem: 1.0 per pillar - 지지 hidden_stems: 본기 1.0, 중기 0.5, 여기 0.3 - 결과는 소수점 둘째 자리로 반올림 (TS Math.round(x*100)/100). """ balance: dict[str, float] = {"木": 0.0, "火": 0.0, "土": 0.0, "金": 0.0, "水": 0.0} # 천간 stems = [saju["year"]["stem"], saju["month"]["stem"], saju["day"]["stem"]] if saju.get("hour") is not None: stems.append(saju["hour"]["stem"]) for stem in stems: elem = FIVE_ELEMENTS.get(stem) if elem in balance: balance[elem] += 1.0 # 지지 지장간 branches = [saju["year"]["branch"], saju["month"]["branch"], saju["day"]["branch"]] if saju.get("hour") is not None: branches.append(saju["hour"]["branch"]) weights = [1.0, 0.5, 0.3] for branch in branches: hidden = get_hidden_stems(branch) for i, hs in enumerate(hidden): elem = FIVE_ELEMENTS.get(hs) if elem in balance: w = weights[i] if i < len(weights) else 0.3 balance[elem] += w # 소수점 둘째 자리 반올림 (TS Math.round(x*100)/100) for k in list(balance.keys()): balance[k] = _js_round(balance[k] * 100) / 100 return balance def calculate_element_score(saju: dict) -> dict: """오행 비율 (%). TS: Math.round((value / total) * 100).""" balance = calculate_detailed_element_balance(saju) total = sum(balance.values()) scores: dict[str, int] = {} for elem, value in balance.items(): if total > 0: scores[elem] = _js_round((value / total) * 100) else: scores[elem] = 0 return scores # ─── 신강/신약 분석 ─────────────────────────────────────────────── def analyze_day_master_strength(saju: dict) -> dict: """신강/신약/중화 판단 — TS analyzeDayMasterStrength 와 1:1 동일. score 가산/감산 규칙: 1. 월령 득령 - 월지 본기 == 일간 오행: +3 ("월령 득령: 월지 X(Y)의 본기가 일간과 같은 ...") - 월지 본기 == 인성: +2 ("월령 득령: 월지 X(Y)의 본기가 일간을 생하는 ...") - 기타: -2 ("월령 실령: 월지 X(Y)의 본기가 일간을 돕지 않음") 2. 통근 (지지 지장간 중 일간/인성 오행 포함된 지지 개수) - >=3: +2 / >=2: +1 / 그 외: -1 3. 투출 (년주/월주/시주 천간 중 일간/인성 오행 개수, 일주 제외) - >=2: +2 / ==1: +1 / 0: -1 4. 오행 비율 (조력 > 약화 * 1.3 또는 반대) - +1 / -1 결과: score >= 3 → 신강, score <= -2 → 신약, else 중화. """ day_stem = saju["day_stem"] day_element = FIVE_ELEMENTS[day_stem] producing_element = _get_producing_element(day_element) reasons: list[str] = [] score = 0 # 1. 월령 득령 month_branch = saju["month"]["branch"] month_branch_kr = saju["month"]["branch_kr"] month_hidden = get_hidden_stems(month_branch) month_main_element = FIVE_ELEMENTS[month_hidden[0]] if month_main_element == day_element: score += 3 reasons.append( f"월령 득령: 월지 {month_branch_kr}({month_branch})의 본기가 일간과 같은 " f"{FIVE_ELEMENTS_KR[day_element]}으로 강한 힘을 받음" ) elif month_main_element == producing_element: score += 2 reasons.append( f"월령 득령: 월지 {month_branch_kr}({month_branch})의 본기가 일간을 생하는 " f"{FIVE_ELEMENTS_KR[producing_element]}으로 힘을 받음" ) else: score -= 2 reasons.append( f"월령 실령: 월지 {month_branch_kr}({month_branch})의 본기가 일간을 돕지 않음" ) # 2. 통근 (4주 지지 모두 검사) all_branches = [saju["year"]["branch"], saju["month"]["branch"], saju["day"]["branch"]] if saju.get("hour") is not None: all_branches.append(saju["hour"]["branch"]) root_count = 0 for branch in all_branches: hidden = get_hidden_stems(branch) for h in hidden: h_elem = FIVE_ELEMENTS.get(h) if h_elem == day_element or h_elem == producing_element: root_count += 1 break if root_count >= 3: score += 2 reasons.append(f"통근 강함: {root_count}개 지지에서 일간의 뿌리를 찾음") elif root_count >= 2: score += 1 reasons.append(f"통근 보통: {root_count}개 지지에서 일간의 뿌리를 찾음") else: score -= 1 reasons.append(f"통근 약함: {root_count}개 지지에서만 일간의 뿌리를 찾음") # 3. 투출 (년주/월주 천간 + 시주 천간, 일주 제외) all_stems = [saju["year"]["stem"], saju["month"]["stem"]] if saju.get("hour") is not None: all_stems.append(saju["hour"]["stem"]) helping_stem_count = 0 for stem in all_stems: stem_elem = FIVE_ELEMENTS.get(stem) if stem_elem == day_element or stem_elem == producing_element: helping_stem_count += 1 if helping_stem_count >= 2: score += 2 reasons.append( f"투출 강함: 천간에 비겁/인성이 {helping_stem_count}개 있어 일간을 도움" ) elif helping_stem_count == 1: score += 1 reasons.append("투출 보통: 천간에 비겁/인성이 1개 있음") else: score -= 1 reasons.append("투출 없음: 천간에 일간을 돕는 비겁/인성이 없음") # 4. 오행 비율 (조력 vs 약화) balance = calculate_detailed_element_balance(saju) helping_score = balance.get(day_element, 0.0) + balance.get(producing_element, 0.0) draining_score = sum( v for k, v in balance.items() if k != day_element and k != producing_element ) if helping_score > draining_score * 1.3: score += 1 reasons.append( f"오행 비율: 비겁+인성({helping_score:.1f}) > 식상+재관({draining_score:.1f}) " f"→ 일간 세력 우세" ) elif draining_score > helping_score * 1.3: score -= 1 reasons.append( f"오행 비율: 식상+재관({draining_score:.1f}) > 비겁+인성({helping_score:.1f}) " f"→ 일간 세력 열세" ) if score >= 3: result = "신강" elif score <= -2: result = "신약" else: result = "중화" return {"result": result, "score": score, "reasons": reasons} # ─── 용신 추정 ──────────────────────────────────────────────────── def estimate_yongshin(saju: dict, strength: dict) -> dict: """용신/희신/기신 추정 — TS estimateYongShin 와 1:1 동일. 신강: 용신/희신 = 식상/재성/관살 중 balance 가장 낮은 2개 (점수 오름차순) 기신 = 일간 오행 (비겁) 신약: 용신/희신 = 인성/비겁 중 balance 낮은 2개 기신 = 관살 중화: 용신/희신 = 모든 오행 중 balance 낮은 2개 기신 = balance 가장 높은 오행 """ day_element = FIVE_ELEMENTS[saju["day_stem"]] balance = calculate_detailed_element_balance(saju) producing_me = _get_producing_element(day_element) # 인성 my_product = _PRODUCE_MAP[day_element] # 식상 my_overcome = _OVERCOME_MAP[day_element] # 재성 overcome_me = _get_overcoming_me(day_element) # 관살 def kr(e: str) -> str: return FIVE_ELEMENTS_KR.get(e, e) result = strength["result"] if result == "신강": candidates = [ {"elem": my_product, "score": balance.get(my_product, 0.0), "name": "식상"}, {"elem": my_overcome, "score": balance.get(my_overcome, 0.0), "name": "재성"}, {"elem": overcome_me, "score": balance.get(overcome_me, 0.0), "name": "관살"}, ] candidates.sort(key=lambda x: x["score"]) yong = candidates[0] hee = candidates[1] return { "yong_shin": yong["elem"], "yong_shin_kr": kr(yong["elem"]), "hee_shin": hee["elem"], "hee_shin_kr": kr(hee["elem"]), "gi_shin": day_element, "gi_shin_kr": kr(day_element), "explanation": ( f"신강한 사주로 일간의 힘이 넘치므로 {yong['name']}({kr(yong['elem'])}) " f"기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. " f"{hee['name']}({kr(hee['elem'])})이 희신으로 보조합니다." ), } elif result == "신약": candidates = [ {"elem": producing_me, "score": balance.get(producing_me, 0.0), "name": "인성"}, {"elem": day_element, "score": balance.get(day_element, 0.0), "name": "비겁"}, ] candidates.sort(key=lambda x: x["score"]) yong = candidates[0] hee = candidates[1] return { "yong_shin": yong["elem"], "yong_shin_kr": kr(yong["elem"]), "hee_shin": hee["elem"], "hee_shin_kr": kr(hee["elem"]), "gi_shin": overcome_me, "gi_shin_kr": kr(overcome_me), "explanation": ( f"신약한 사주로 일간의 힘이 부족하므로 {yong['name']}({kr(yong['elem'])}) " f"기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. " f"{hee['name']}({kr(hee['elem'])})이 희신으로 보조합니다." ), } else: # 중화 — Object.entries 순서 (TS): 木, 火, 土, 金, 水 entries = [(e, balance[e]) for e in ["木", "火", "土", "金", "水"]] # Array.prototype.sort 는 안정 정렬(Node v12+). Python sort 도 안정 정렬이므로 동일. entries.sort(key=lambda x: x[1]) yong = entries[0] hee = entries[1] gi = entries[-1] return { "yong_shin": yong[0], "yong_shin_kr": kr(yong[0]), "hee_shin": hee[0], "hee_shin_kr": kr(hee[0]), "gi_shin": gi[0], "gi_shin_kr": kr(gi[0]), "explanation": ( f"중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. " f"가장 부족한 {kr(yong[0])}({yong[0]}) 기운을 보충하면 더욱 좋아집니다." ), } # ─── 세운 ───────────────────────────────────────────────────────── _SEUN_CHUNG_PAIRS: list[tuple[str, str]] = [ ("子", "午"), ("丑", "未"), ("寅", "申"), ("卯", "酉"), ("辰", "戌"), ("巳", "亥"), ] _SEUN_YUKAP_PAIRS: list[tuple[str, str, str]] = [ ("子", "丑", "土"), ("寅", "亥", "木"), ("卯", "戌", "火"), ("辰", "酉", "金"), ("巳", "申", "水"), ("午", "未", "火"), ] def calculate_seun(year: int, saju: dict) -> dict: """올해(주어진 year) 세운 정보 + 일주/년월시 지지와의 충/합 매핑. TS calculateSeun 와 1:1 동일. interactions 는 충(沖) 먼저 -> 합(合) 순서. """ ganzi = get_year_ganzi(year) element = FIVE_ELEMENTS[ganzi["stem"]] element_kr = FIVE_ELEMENTS_KR[element] seun_branch = ganzi["branch"] seun_branch_kr = ganzi["branch_kr"] 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": "시주", }) interactions: list[dict] = [] # 충 (沖) for a, b in _SEUN_CHUNG_PAIRS: for pb in pillar_branches: if (seun_branch == a and pb["branch"] == b) or (seun_branch == b and pb["branch"] == a): interactions.append({ "type": "충(沖)", "branches": [seun_branch, pb["branch"]], "branches_kr": [seun_branch_kr, pb["branch_kr"]], "pillars": ["세운", pb["pillar"]], "description": ( f"세운 {seun_branch_kr}와 {pb['pillar']} {pb['branch_kr']}가 충 " f"→ 해당 영역에 변동과 변화가 예상됨." ), }) # 합 (六合) for a, b, elem in _SEUN_YUKAP_PAIRS: for pb in pillar_branches: if (seun_branch == a and pb["branch"] == b) or (seun_branch == b and pb["branch"] == a): interactions.append({ "type": "합(合)", "branches": [seun_branch, pb["branch"]], "branches_kr": [seun_branch_kr, pb["branch_kr"]], "pillars": ["세운", pb["pillar"]], "description": ( f"세운 {seun_branch_kr}와 {pb['pillar']} {pb['branch_kr']}가 합 " f"→ 해당 영역에 조화와 좋은 인연이 기대됨." ), "result_element": elem, }) return { "stem": ganzi["stem"], "branch": ganzi["branch"], "stem_kr": ganzi["stem_kr"], "branch_kr": ganzi["branch_kr"], "element": element, "element_kr": element_kr, "year": year, "interactions": interactions, } # ─── 통합 분석 ──────────────────────────────────────────────────── def perform_full_analysis(saju: dict, current_year: int) -> dict: """모든 분석 통합 — TS performFullAnalysis 와 1:1 동일. 반환 키는 snake_case. """ element_balance = calculate_detailed_element_balance(saju) element_scores = calculate_element_score(saju) day_master_strength = analyze_day_master_strength(saju) yong_shin = estimate_yongshin(saju, day_master_strength) branch_interactions = analyze_branch_interactions(saju) shinsal = calculate_shinsal(saju) gongmang = calculate_gongmang(saju["day_stem"], saju["day"]["branch"]) seun = calculate_seun(current_year, saju) hidden_stems = get_all_hidden_stems(saju) return { "element_balance": element_balance, "element_scores": element_scores, "day_master_strength": day_master_strength, "yong_shin": yong_shin, "branch_interactions": branch_interactions, "shinsal": shinsal, "gongmang": gongmang, "seun": seun, "hidden_stems": hidden_stems, }