diff --git a/saju-lab/app/calculator/core.py b/saju-lab/app/calculator/core.py new file mode 100644 index 0000000..fb75e0f --- /dev/null +++ b/saju-lab/app/calculator/core.py @@ -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 diff --git a/saju-lab/tests/test_core.py b/saju-lab/tests/test_core.py new file mode 100644 index 0000000..ec9361b --- /dev/null +++ b/saju-lab/tests/test_core.py @@ -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"]