feat(saju-lab): core.py — 60갑자 + 십성 + 십이운성 + calculate_saju (30/30 reference pass)

This commit is contained in:
2026-05-25 19:48:28 +09:00
parent f91a74237b
commit 3f0b7bcd74
2 changed files with 339 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
"""사주 핵심 계산 — 60갑자 + 십성 + 십이운성 + calculate_saju.
saju-web/lib/saju-calculator.ts 의 알고리즘과 1:1 매핑.
핵심 결정 사항:
- 년주: 1900-01-01 = 庚子년(stem=6, branch=0) 기준 단순 차분 (TS 동일)
- 월주: 月支 = 절기 기반 (sxtwl), 月干 = (yearStemIndex * 2 + branchIndex) % 10 (TS 동일)
- 일주: 1900-01-01 = 丙寅일(stem=2, branch=2) 기준 그레고리안 일수 차분 (TS 동일, sxtwl 미사용)
- 시주: branchIndex = hour-to-branch 매핑, stemIndex = (dayStemIndex * 2 + branchIndex) % 10
- 십성: 일간/대상 오행 관계 + isYang 플래그 (호출자가 음양 동일 여부 결정)
- 십이운성: 일간×지지 12 갑자 하드코딩 매핑 (건록, '임관' 아님)
"""
from __future__ import annotations
from datetime import date as _date
from typing import Optional
from .constants import (
HEAVENLY_STEMS,
HEAVENLY_STEMS_KR,
EARTHLY_BRANCHES,
EARTHLY_BRANCHES_KR,
FIVE_ELEMENTS,
)
from .solar_terms import get_solar_term_month_branch
# ─── 기준일 (TS saju-calculator.ts와 동일) ────────────────────────────
BASE_YEAR = 1900
BASE_YEAR_STEM = 6 # 庚
BASE_YEAR_BRANCH = 0 # 子
BASE_DAY_STEM = 2 # 丙
BASE_DAY_BRANCH = 2 # 寅
_BASE_DATE = _date(1900, 1, 1)
# ─── 십이운성 ─────────────────────────────────────────────────────
TWELVE_FORTUNES = [
"장생", "목욕", "관대", "건록", "제왕", "",
"", "", "", "", "", "",
]
# 일간 × 지지 → TWELVE_FORTUNES 인덱스
# saju-web TS getTwelveFortune() 함수의 hardcoded map과 1:1 일치
_FORTUNE_MAP: dict[str, dict[str, int]] = {
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
"": {"": 11, "": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10},
}
# ─── 오행 상생/상극 (TS getTenGod 내부 매핑과 동일) ───────────────────
_PRODUCE_MAP = {"": "", "": "", "": "", "": "", "": ""}
_OVERCOME_MAP = {"": "", "": "", "": "", "": "", "": ""}
# ─── 헬퍼 ─────────────────────────────────────────────────────────
def _make_pillar(stem_idx: int, branch_idx: int) -> dict:
"""천간/지지 인덱스 → pillar 기본 필드 (element 포함)."""
stem = HEAVENLY_STEMS[stem_idx]
branch = EARTHLY_BRANCHES[branch_idx]
return {
"stem": stem,
"branch": branch,
"stem_kr": HEAVENLY_STEMS_KR[stem_idx],
"branch_kr": EARTHLY_BRANCHES_KR[branch_idx],
"element": FIVE_ELEMENTS[stem],
}
# ─── 60갑자 ───────────────────────────────────────────────────────
def get_year_ganzi(year: int) -> dict:
"""년주 — 1900년 庚子 기준 단순 차분 (TS getYearGanzi 동일)."""
year_diff = year - BASE_YEAR
stem_idx = (BASE_YEAR_STEM + year_diff) % 10
branch_idx = (BASE_YEAR_BRANCH + year_diff) % 12
# 음수 보정
if stem_idx < 0:
stem_idx += 10
if branch_idx < 0:
branch_idx += 12
return _make_pillar(stem_idx, branch_idx)
def get_month_ganzi(year: int, month: int, day: int) -> dict:
"""월주 — 月支 = 절기 기반, 月干 = (yearStemIdx*2 + branchIdx) % 10.
TS getMonthGanzi와 동일.
"""
branch_idx = get_solar_term_month_branch(year, month, day)
year_stem = get_year_ganzi(year)["stem"]
year_stem_idx = HEAVENLY_STEMS.index(year_stem)
stem_idx = (year_stem_idx * 2 + branch_idx) % 10
return _make_pillar(stem_idx, branch_idx)
def get_day_ganzi(year: int, month: int, day: int) -> dict:
"""일주 — 1900-01-01(丙寅) 기준 그레고리안 일수 차분 (TS getDayGanzi 동일).
sxtwl을 사용하지 않음 — TS와 1:1 일치를 위해 단순 일수 차분.
⚠️ TS는 JavaScript `new Date(1900, 0, 1).getTime()`을 사용하는데, Asia/Seoul
timezone에서 1900-01-01 자정은 LMT(+8:27:52) 보정 때문에 UTC 기준으로
1899-12-31T15:32:08Z에 해당. 반면 후대 날짜는 KST(+9:00) 기준이라
Math.floor((target - base) / DAY)는 정확한 일수보다 1 작게 계산됨
(모든 케이스가 균일하게 -1). 본 함수는 그 결과를 재현하기 위해 days_diff -= 1.
"""
target = _date(year, month, day)
days_diff = (target - _BASE_DATE).days - 1 # TS Asia/Seoul LMT artifact 보정
stem_idx = (BASE_DAY_STEM + days_diff) % 10
branch_idx = (BASE_DAY_BRANCH + days_diff) % 12
if stem_idx < 0:
stem_idx += 10
if branch_idx < 0:
branch_idx += 12
return _make_pillar(stem_idx, branch_idx)
def _hour_to_branch_idx(hour: int) -> int:
"""시간 → 시지 인덱스 (TS getHourGanzi 내부 분기 동일).
23~01 = 子(0), 01~03 = 丑(1), 03~05 = 寅(2), ..., 21~23 = 亥(11)
"""
if hour >= 23 or hour < 1:
return 0
if 1 <= hour < 3:
return 1
if 3 <= hour < 5:
return 2
if 5 <= hour < 7:
return 3
if 7 <= hour < 9:
return 4
if 9 <= hour < 11:
return 5
if 11 <= hour < 13:
return 6
if 13 <= hour < 15:
return 7
if 15 <= hour < 17:
return 8
if 17 <= hour < 19:
return 9
if 19 <= hour < 21:
return 10
return 11 # 21~23
def get_hour_ganzi(day_stem: str, hour: int) -> dict:
"""시주 — branchIdx = hour-to-branch, stemIdx = (dayStemIdx*2 + branchIdx) % 10.
TS getHourGanzi와 동일 (五鼠遁訣 정통 공식이 아닌 단순 *2 패턴).
"""
branch_idx = _hour_to_branch_idx(hour)
day_stem_idx = HEAVENLY_STEMS.index(day_stem)
stem_idx = (day_stem_idx * 2 + branch_idx) % 10
return _make_pillar(stem_idx, branch_idx)
# ─── 십성 ─────────────────────────────────────────────────────────
def get_ten_god(day_stem: str, target_stem: str, is_yang: bool) -> str:
"""십성 — 일간/대상 오행 관계 + is_yang (TS getTenGod 동일).
is_yang은 호출자가 '대상이 일간과 동일한 음양인가?'로 미리 계산해서 전달.
같은 음양 → 비견/식신/편재/편관/편인
다른 음양 → 겁재/상관/정재/정관/정인
"""
day_element = FIVE_ELEMENTS[day_stem]
target_element = FIVE_ELEMENTS[target_stem]
if day_element == target_element:
return "비견" if is_yang else "겁재"
if _PRODUCE_MAP[day_element] == target_element:
return "식신" if is_yang else "상관"
if _OVERCOME_MAP[day_element] == target_element:
return "편재" if is_yang else "정재"
if _OVERCOME_MAP[target_element] == day_element:
return "편관" if is_yang else "정관"
if _PRODUCE_MAP[target_element] == day_element:
return "편인" if is_yang else "정인"
return "비견"
# ─── 십이운성 ─────────────────────────────────────────────────────
def get_twelve_fortune(day_stem: str, branch: str) -> str:
"""십이운성 — 일간×지지 하드코딩 매핑 (TS getTwelveFortune 동일)."""
idx = _FORTUNE_MAP.get(day_stem, {}).get(branch, 0)
return TWELVE_FORTUNES[idx]
# ─── 사주 통합 계산 ────────────────────────────────────────────────
def calculate_saju(
year: int,
month: int,
day: int,
hour: Optional[int],
gender: str,
) -> dict:
"""사주팔자 전체 계산 — TS calculateSaju와 1:1 일치.
반환 키는 snake_case (year, month, day, hour, day_stem, birth_date, gender).
"""
year_pillar = get_year_ganzi(year)
month_pillar = get_month_ganzi(year, month, day)
day_pillar = get_day_ganzi(year, month, day)
day_stem = day_pillar["stem"]
day_stem_idx = HEAVENLY_STEMS.index(day_stem)
is_day_yang = (day_stem_idx % 2) == 0
def _ten_god_for(stem: str) -> str:
target_idx = HEAVENLY_STEMS.index(stem)
target_yang = (target_idx % 2) == 0
# is_yang 인자는 '대상이 일간과 같은 음양인가?'
return get_ten_god(day_stem, stem, target_yang == is_day_yang)
# 년주 — ten_god + fortune 부착
year_pillar["ten_god"] = _ten_god_for(year_pillar["stem"])
year_pillar["fortune"] = get_twelve_fortune(day_stem, year_pillar["branch"])
# 월주
month_pillar["ten_god"] = _ten_god_for(month_pillar["stem"])
month_pillar["fortune"] = get_twelve_fortune(day_stem, month_pillar["branch"])
# 일주 — ten_god='일간'
day_pillar["ten_god"] = "일간"
day_pillar["fortune"] = get_twelve_fortune(day_stem, day_pillar["branch"])
birth_date: dict = {"year": year, "month": month, "day": day}
result: dict = {
"year": year_pillar,
"month": month_pillar,
"day": day_pillar,
"day_stem": day_stem,
"birth_date": birth_date,
"gender": gender,
}
# 시주
if hour is not None:
hour_pillar = get_hour_ganzi(day_stem, hour)
hour_pillar["ten_god"] = _ten_god_for(hour_pillar["stem"])
hour_pillar["fortune"] = get_twelve_fortune(day_stem, hour_pillar["branch"])
result["hour"] = hour_pillar
birth_date["hour"] = hour
return result

View File

@@ -0,0 +1,78 @@
"""calculator/core.py — saju-web TS 엔진과 1:1 매칭 검증.
fixtures/reference_saju.json 의 30 case (TS calculateSaju 결과)와 Python 구현 일치 여부 검증.
"""
import json
from pathlib import Path
import pytest
from app.calculator.core import calculate_saju
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
@pytest.mark.parametrize(
"case",
REF,
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}-{c['input'].get('hour')}-{c['input']['gender']}",
)
def test_calculate_saju_matches_reference(case):
inp = case["input"]
expected = _normalize(case["expected"]["saju"])
actual = calculate_saju(
inp["year"], inp["month"], inp["day"],
inp.get("hour"), inp["gender"],
)
# 4기둥 비교
for pillar in ["year", "month", "day", "hour"]:
if expected.get(pillar) is None:
assert actual.get(pillar) is None, f"{pillar} should be None"
continue
if pillar == "hour" and inp.get("hour") is None:
assert actual.get(pillar) is None
continue
ep = expected[pillar]
ap = actual[pillar]
for field in ["stem", "branch", "stem_kr", "branch_kr", "element", "ten_god", "fortune"]:
assert ap[field] == ep[field], (
f"{pillar}.{field}: actual={ap[field]} expected={ep[field]} "
f"(input={inp})"
)
# day_stem
assert actual["day_stem"] == expected["day_stem"]
# gender
assert actual["gender"] == expected["gender"]
# birth_date
bd = actual["birth_date"]
assert bd["year"] == inp["year"]
assert bd["month"] == inp["month"]
assert bd["day"] == inp["day"]
if inp.get("hour") is not None:
assert bd.get("hour") == inp["hour"]