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>
This commit is contained in:
446
saju-lab/app/calculator/analysis.py
Normal file
446
saju-lab/app/calculator/analysis.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""사주 종합 분석 — 오행/신강신약/용신/세운/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,
|
||||
}
|
||||
Reference in New Issue
Block a user