Files
web-page-backend/saju-lab/app/calculator/core.py

262 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""사주 핵심 계산 — 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