diff --git a/saju-lab/app/calculator/daeun.py b/saju-lab/app/calculator/daeun.py new file mode 100644 index 0000000..e7e80b4 --- /dev/null +++ b/saju-lab/app/calculator/daeun.py @@ -0,0 +1,277 @@ +"""대운(大運) 계산 — saju-web/lib/daeun-calculator.ts 와 1:1 매핑. + +핵심 알고리즘: +1. 방향 결정 (양남음녀=순행, 음남양녀=역행) + - 양/음은 년간 인덱스 % 2 == 0 여부 (TS: yearStemIndex = (year - 1900 + 6) % 10) +2. 시작 나이 계산 (절기 기준) + - 순행: 다음 절기까지 일수 / 3 (floor) + - 역행: 이전 절기부터 일수 / 3 (TS는 Math.ceil 사용) + - clamp [1, 10] +3. 8개 대운 — 시작 나이부터 10년 단위, 월주에서 ±1씩 60갑자 진행 + +⚠️ TS `getCurrentSolarTerm`의 미묘한 동작을 재현: + - TS는 saju-web 인덱스 i=23(대한)부터 i=0(입춘) 순서로 내려가며, + `getSolarTermDate(year, i)` 의 날짜와 비교하여 첫 번째 매치를 반환. + - 대한(i=23)은 1월 20일경이라 같은 해의 대부분의 날짜가 매치 → currentTerm=23이 + 대다수. + - 매치 못 찾으면 폴백 23 반환 → 입춘 이전 (1월 초·중순) 또한 23으로 처리됨. + - 결과적으로 forward 의 days 가 매우 크게 잡혀 startAge=10으로 cap 되는 게 보통. + - 본 구현은 sxtwl 정확 날짜로 동일한 비교 로직을 수행 → TS 결과 정확 재현. + +⚠️ saju-web 인덱스 vs sxtwl JieQi 인덱스: + - saju-web 0=입춘 → sxtwl 3=立春 + - 변환: sxtwl_idx = (web_idx + 3) % 24 +""" +from __future__ import annotations + +from datetime import date as _date +from functools import lru_cache +from typing import Optional + +from .constants import ( + HEAVENLY_STEMS, + HEAVENLY_STEMS_KR, + EARTHLY_BRANCHES, + EARTHLY_BRANCHES_KR, +) +from .solar_terms import _all_jieqi_dates + + +# saju-web 절기 인덱스 → sxtwl JieQi 인덱스 +# 0=입춘, 1=우수, 2=경칩, ..., 22=소한, 23=대한 (saju-web) +# 3=立春, 4=雨水, 5=驚蟄, ..., 1=小寒, 2=大寒 (sxtwl) +def _web_to_sxtwl_idx(web_idx: int) -> int: + return (web_idx + 3) % 24 + + +@lru_cache(maxsize=512) +def _saju_web_solar_term_date(year: int, web_idx: int) -> _date: + """saju-web 인덱스 기준 `year` 의 절기 날짜 반환 (정확 날짜). + + TS `getSolarTermDate(year, termIndex)` 의 1:1 대응. + sxtwl 으로 정확한 천문학적 날짜를 사용 (TS의 search-range 근사보다 정확). + + `year` 는 양력 그레고리안 연도. 예를 들어 大寒(saju-web 23)의 1985년 날짜는 + 1985-01-20이며, sxtwl 에서는 getJieQiByYear(1984) 의 결과에 idx=2로 포함됨. + """ + sxtwl_idx = _web_to_sxtwl_idx(web_idx) + # 해당 sxtwl_idx 의 절기 중 그레고리안 연도가 일치하는 날짜 검색. + # sxtwl 의 getJieQiByYear(y) 는 立春 y년부터 그 다음 立春 직전(=大寒 y+1년)까지 포함. + for src_year in (year - 1, year, year + 1): + for d, idx in _all_jieqi_dates(src_year): + if idx == sxtwl_idx and d.year == year: + return d + # 폴백 — 정상 동작 시 도달 불가 + base_month = [ + 2, 2, 3, 3, 4, 4, + 5, 5, 6, 6, 7, 7, + 8, 8, 9, 9, 10, 10, + 11, 11, 12, 12, 1, 1, + ] + base_day = [ + 4, 19, 5, 20, 4, 20, + 5, 21, 6, 21, 7, 23, + 7, 23, 8, 23, 8, 23, + 7, 22, 7, 22, 5, 20, + ] + return _date(year, base_month[web_idx], base_day[web_idx]) + + +def _get_current_solar_term_web(year: int, month: int, day: int) -> int: + """TS getCurrentSolarTerm 의 1:1 재현. + + saju-web 절기 인덱스 (0=입춘 ... 23=대한) 중 birth date 이상인 가장 큰 i 반환. + 탐색 순서: i=23 → i=0. i=23 (대한, 1월 20일경) 매치 시 즉시 반환되므로 + 대부분의 날짜가 23을 반환. 매치 못 찾으면 폴백 23. + + TS의 특수 처리: i >= 22 (소한, 대한) 이고 birthMonth >= 2 면 termYear=year, + 그 외 i >= 22 는 termYear = year - 1. 이는 1월 출생자가 전년도 대한·소한 기준이 됨을 의미. + 본 함수는 i >= 22 일 때 TS와 동일한 termYear 조정 후 비교. + """ + target = _date(year, month, day) + + for i in range(23, -1, -1): + # 기본은 birthYear 와 동일 연도의 절기 날짜 + term_date = _saju_web_solar_term_date(year, i) + term_year = year + term_month = term_date.month + term_day = term_date.day + + # 대한(23) / 소한(22) 특수 처리 — birthMonth < 2 (1월)면 전년도 절기 + if i >= 22 and month < 2: + term_year = year - 1 + shifted = _saju_web_solar_term_date(term_year, i) + term_month = shifted.month + term_day = shifted.day + + try: + term = _date(term_year, term_month, term_day) + except ValueError: + continue + + if target >= term: + return i + + # 입춘 이전 (1월 초) — TS는 23 반환 + return 23 + + +def _calculate_daeun_start_age( + year: int, month: int, day: int, gender: str, is_yang_year: bool +) -> int: + """대운 시작 나이 정밀 계산 (절기 기준). + + TS calculateDaeunStartAge 와 1:1 매핑. + - 양남(陽男) / 음녀(陰女) → 순행: 다음 절기까지 일수 + - 음남(陰男) / 양녀(陽女) → 역행: 이전 절기부터 일수 + - startAge = floor(days / 3), clamp [1, 10] + """ + target = _date(year, month, day) + current_term = _get_current_solar_term_web(year, month, day) + + if (gender == "male" and is_yang_year) or (gender == "female" and not is_yang_year): + # 순행 — 다음 절기까지 + next_term_index = (current_term + 1) % 24 + next_year = year + 1 if current_term == 23 else year + next_date = _saju_web_solar_term_date(next_year, next_term_index) + diff = next_date - target + # TS: Math.ceil(diffTime / DAY) — date 차분은 정수이므로 그대로 + days = diff.days + else: + # 역행 — 이전 절기부터 + term_date = _saju_web_solar_term_date(year, current_term) + term_year = year + term_month = term_date.month + # TS: 대한, 소한 처리 — i >= 22 일 때 + if current_term >= 22 and month >= 2: + term_year = year + elif current_term >= 22: + term_year = year - 1 + term_date = _saju_web_solar_term_date(term_year, current_term) + try: + term_dt = _date(term_year, term_month, term_date.day) + except ValueError: + term_dt = term_date + diff = target - term_dt + days = diff.days + + start_age = days // 3 # Math.floor + + # 최소 1세, 최대 10세 + return max(1, min(10, start_age)) + + +def calculate_daeun( + year: int, + month: int, + day: int, + gender: str, + month_stem: str, + month_branch: str, +) -> list[dict]: + """8개 대운 계산. + + Args: + year, month, day: 양력 생년월일 + gender: "male" | "female" + month_stem, month_branch: 월주 천간/지지 (사주 계산 후 전달) + + Returns: + 8개 DaeunPillar dict (snake_case 키): + - age: 시작 나이 + - start_year: 시작 년도 + - end_year: 끝 년도 (start_year + 9) + - stem, branch: 천간/지지 + - stem_kr, branch_kr: 한글 + """ + try: + month_stem_idx = HEAVENLY_STEMS.index(month_stem) + month_branch_idx = EARTHLY_BRANCHES.index(month_branch) + except ValueError: + return [] + + # 양남음녀(陽男陰女)=순행, 음남양녀(陰男陽女)=역행 + # TS: yearStemIndex = (year - 1900 + 6) % 10 → isYangYear = (idx % 2 == 0) + year_stem_idx = (year - 1900 + 6) % 10 + if year_stem_idx < 0: + year_stem_idx += 10 + is_yang_year = (year_stem_idx % 2) == 0 + + if gender == "male": + is_forward = is_yang_year + else: + is_forward = not is_yang_year + + start_age = _calculate_daeun_start_age(year, month, day, gender, is_yang_year) + + daeun_list: list[dict] = [] + for i in range(8): + age = start_age + (i * 10) + start_year = year + age + end_year = start_year + 9 + + if is_forward: + stem_idx = (month_stem_idx + i + 1) % 10 + branch_idx = (month_branch_idx + i + 1) % 12 + else: + stem_idx = (month_stem_idx - i - 1 + 100) % 10 + branch_idx = (month_branch_idx - i - 1 + 120) % 12 + + daeun_list.append({ + "age": age, + "start_year": start_year, + "end_year": end_year, + "stem": HEAVENLY_STEMS[stem_idx], + "branch": EARTHLY_BRANCHES[branch_idx], + "stem_kr": HEAVENLY_STEMS_KR[stem_idx], + "branch_kr": EARTHLY_BRANCHES_KR[branch_idx], + }) + + return daeun_list + + +def get_current_daeun(daeun_list: list[dict], current_year: int) -> Optional[dict]: + """현재 연도에 해당하는 대운 반환 (없으면 None).""" + for d in daeun_list: + if d["start_year"] <= current_year <= d["end_year"]: + return d + return None + + +def get_daeun_description(daeun: dict, day_stem: str) -> str: + """대운 해석 — TS getDaeunDescription 와 1:1 매핑. + + daeun dict 키는 snake_case (age, stem, branch, stem_kr, branch_kr). + """ + age = daeun["age"] + stem = daeun["stem"] + branch = daeun["branch"] + stem_kr = daeun["stem_kr"] + branch_kr = daeun["branch_kr"] + ganzi = f"{stem}{branch}" + + description = ( + f"{age}세부터 {age + 9}세까지의 10년은 {stem_kr}{branch_kr}({ganzi}) 대운입니다. " + ) + + # 대운 천간 인덱스 (음양 판정용) + try: + stem_idx = HEAVENLY_STEMS.index(stem) + except ValueError: + stem_idx = 0 + + if age < 20: + description += "청소년기로 학업과 기초를 다지는 시기입니다. " + elif age < 40: + description += "성장과 발전의 시기로 사회활동이 왕성한 때입니다. " + elif age < 60: + description += "안정과 성숙의 시기로 경험이 쌓이는 때입니다. " + else: + description += "원숙한 시기로 인생의 지혜를 나누는 때입니다. " + + if stem_idx % 2 == 0: + description += "적극적이고 외향적인 활동이 유리합니다." + else: + description += "차분하고 내실을 다지는 것이 좋습니다." + + return description diff --git a/saju-lab/tests/test_daeun.py b/saju-lab/tests/test_daeun.py new file mode 100644 index 0000000..a8f2ea3 --- /dev/null +++ b/saju-lab/tests/test_daeun.py @@ -0,0 +1,107 @@ +"""daeun.py — 대운 8개 계산 검증. + +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.daeun import calculate_daeun, get_current_daeun, get_daeun_description + + +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']['gender']}", +) +def test_calculate_daeun_matches_reference(case): + inp = case["input"] + expected = [_normalize(x) for x in case["expected"]["daeun"]] + + saju = calculate_saju( + inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"] + ) + actual = calculate_daeun( + inp["year"], + inp["month"], + inp["day"], + inp["gender"], + saju["month"]["stem"], + saju["month"]["branch"], + ) + + assert len(actual) == len(expected), ( + f"len mismatch for {inp}: {len(actual)} vs {len(expected)}" + ) + + for i, (a, e) in enumerate(zip(actual, expected)): + for key in ("stem", "branch", "stem_kr", "branch_kr"): + assert a.get(key) == e.get(key), ( + f"{inp} daeun[{i}].{key}: actual={a.get(key)} expected={e.get(key)}" + ) + # age + start_year/end_year + assert a.get("age") == e.get("age"), ( + f"{inp} daeun[{i}].age: {a.get('age')} vs {e.get('age')}" + ) + assert a.get("start_year") == e.get("start_year"), ( + f"{inp} daeun[{i}].start_year: {a.get('start_year')} vs {e.get('start_year')}" + ) + assert a.get("end_year") == e.get("end_year"), ( + f"{inp} daeun[{i}].end_year: {a.get('end_year')} vs {e.get('end_year')}" + ) + + +def test_get_current_daeun_returns_match(): + daeun_list = [ + {"age": 10, "start_year": 2000, "end_year": 2009}, + {"age": 20, "start_year": 2010, "end_year": 2019}, + {"age": 30, "start_year": 2020, "end_year": 2029}, + ] + assert get_current_daeun(daeun_list, 2005) == daeun_list[0] + assert get_current_daeun(daeun_list, 2015) == daeun_list[1] + assert get_current_daeun(daeun_list, 2025) == daeun_list[2] + assert get_current_daeun(daeun_list, 1999) is None + assert get_current_daeun(daeun_list, 2030) is None + + +def test_get_daeun_description_returns_string(): + daeun = { + "age": 10, + "start_year": 2000, + "end_year": 2009, + "stem": "壬", + "branch": "午", + "stem_kr": "임", + "branch_kr": "오", + } + desc = get_daeun_description(daeun, "辛") + assert isinstance(desc, str) + assert "임오" in desc + assert "壬午" in desc + assert "10세" in desc + assert "19세" in desc