feat(saju-lab): core.py — 60갑자 + 십성 + 십이운성 + calculate_saju (30/30 reference pass)
This commit is contained in:
261
saju-lab/app/calculator/core.py
Normal file
261
saju-lab/app/calculator/core.py
Normal 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
|
||||
Reference in New Issue
Block a user