Files
web-page-backend/saju-lab/app/calculator/analysis.py
gahusb ebfade655a feat(saju-lab): analysis.py — 오행/신강신약/용신/세운 (30/30 reference)
- calculate_detailed_element_balance: 가중치 오행 점수 (천간 1.0, 본기 1.0, 중기 0.5, 여기 0.3)
- calculate_element_score: 오행 비율 (%) — JS Math.round 호환
- analyze_day_master_strength: 신강/신약/중화 + score + reasons
  (월령 득령/실령, 통근, 투출, 오행 비율 4단계 평가)
- estimate_yongshin: 용신/희신/기신 + explanation
  (신강 → 식상/재성/관살 중 약한 2개, 신약 → 인성/비겁 중 약한 것, 중화 → 5행 중 약한 2개)
- calculate_seun: 올해 세운 + 4주 지지와 충/합 매핑
- perform_full_analysis: 위 5종 + branch_interactions + shinsal + gongmang + hidden_stems 통합

saju-web/lib/ai-interpretation.ts 와 1:1 매핑, 30 reference fixture 모두 통과 (180/180).
전체 saju-lab 테스트 389/389 passed.

JS Math.round 와 Python round() 의 banker-rounding 차이 보정을 위해 _js_round 헬퍼 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:00:36 +09:00

447 lines
17 KiB
Python

"""사주 종합 분석 — 오행/신강신약/용신/세운/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,
}