feat(saju-lab): shinsal.py — 지장간/신살/공망/지지 상호작용
This commit is contained in:
482
saju-lab/app/calculator/shinsal.py
Normal file
482
saju-lab/app/calculator/shinsal.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""신살 + 지장간 + 공망 + 지지 상호작용.
|
||||
|
||||
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"오히려 초월적 능력이 될 수 있음."
|
||||
),
|
||||
}
|
||||
156
saju-lab/tests/test_shinsal.py
Normal file
156
saju-lab/tests/test_shinsal.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""shinsal.py — 지장간/신살/공망/지지 상호작용 검증.
|
||||
|
||||
fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calculator.core import calculate_saju
|
||||
from app.calculator.shinsal import (
|
||||
get_hidden_stems,
|
||||
get_all_hidden_stems,
|
||||
analyze_branch_interactions,
|
||||
calculate_shinsal,
|
||||
calculate_gongmang,
|
||||
)
|
||||
|
||||
|
||||
REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json"
|
||||
REF = json.loads(REF_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch.isupper():
|
||||
out.append("_" + ch.lower())
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize(d):
|
||||
"""TS camelCase → Python snake_case (deep). 값은 변경하지 않음."""
|
||||
if isinstance(d, dict):
|
||||
return {_camel_to_snake(k): _normalize(v) for k, v in d.items()}
|
||||
if isinstance(d, list):
|
||||
return [_normalize(x) for x in d]
|
||||
return d
|
||||
|
||||
|
||||
def _canonical(item):
|
||||
"""dict/list 비교용 — 안정 정렬을 위한 JSON 직렬화."""
|
||||
return json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
# ─── 1. 지장간 단순 테스트 ────────────────────────────────────────────
|
||||
def test_hidden_stems_basic():
|
||||
assert get_hidden_stems("子") == ["癸"]
|
||||
assert get_hidden_stems("丑") == ["己", "癸", "辛"]
|
||||
assert get_hidden_stems("寅") == ["甲", "丙", "戊"]
|
||||
assert get_hidden_stems("卯") == ["乙"]
|
||||
assert get_hidden_stems("辰") == ["戊", "乙", "癸"]
|
||||
assert get_hidden_stems("巳") == ["丙", "庚", "戊"]
|
||||
assert get_hidden_stems("午") == ["丁", "己"]
|
||||
assert get_hidden_stems("未") == ["己", "丁", "乙"]
|
||||
assert get_hidden_stems("申") == ["庚", "壬", "戊"]
|
||||
assert get_hidden_stems("酉") == ["辛"]
|
||||
assert get_hidden_stems("戌") == ["戊", "辛", "丁"]
|
||||
assert get_hidden_stems("亥") == ["壬", "甲"]
|
||||
|
||||
|
||||
# ─── 2. hiddenStems reference 매칭 ───────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_hidden_stems_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("hiddenStems")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no hiddenStems in expected")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = get_all_hidden_stems(saju)
|
||||
assert actual == expected, f"mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
|
||||
|
||||
# ─── 3. branchInteractions reference 매칭 ────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_branch_interactions_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("branchInteractions")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no branchInteractions")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = analyze_branch_interactions(saju)
|
||||
# 순서가 다를 수 있으므로 정렬 후 비교
|
||||
assert sorted(map(_canonical, actual)) == sorted(map(_canonical, expected)), (
|
||||
f"mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 4. shinsal reference 매칭 ───────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_shinsal_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("shinsal")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no shinsal")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = calculate_shinsal(saju)
|
||||
assert sorted(map(_canonical, actual)) == sorted(map(_canonical, expected)), (
|
||||
f"mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 5. gongmang reference 매칭 ──────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_gongmang_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"].get("gongmang")
|
||||
if expected_raw is None:
|
||||
pytest.skip("no gongmang")
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(
|
||||
inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]
|
||||
)
|
||||
actual = calculate_gongmang(saju["day_stem"], saju["day"]["branch"])
|
||||
|
||||
# branches / branches_kr / description 전체 비교
|
||||
assert actual.get("branches") == expected.get("branches"), (
|
||||
f"branches mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
assert actual.get("branches_kr") == expected.get("branches_kr"), (
|
||||
f"branches_kr mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
assert actual.get("description") == expected.get("description"), (
|
||||
f"description mismatch for {inp}\nactual={actual}\nexpected={expected}"
|
||||
)
|
||||
Reference in New Issue
Block a user