feat(saju-lab): shinsal.py — 지장간/신살/공망/지지 상호작용

This commit is contained in:
2026-05-25 19:54:21 +09:00
parent 3f0b7bcd74
commit 234ccfe857
2 changed files with 638 additions and 0 deletions

View 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"오히려 초월적 능력이 될 수 있음."
),
}

View 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}"
)