From 07b5c32f2f59e2ba45d18b362c7c3c28c00b6df1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 19:28:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(saju-lab):=20solar=5Fterms.py=20=E2=80=94?= =?UTF-8?q?=20sxtwl=20=EA=B8=B0=EB=B0=98=2024=EC=A0=88=EA=B8=B0=20+=20?= =?UTF-8?q?=E6=9C=88=E6=94=AF=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- saju-lab/app/calculator/solar_terms.py | 130 +++++++++++++++++++++++++ saju-lab/tests/test_solar_terms.py | 129 ++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 saju-lab/app/calculator/solar_terms.py create mode 100644 saju-lab/tests/test_solar_terms.py diff --git a/saju-lab/app/calculator/solar_terms.py b/saju-lab/app/calculator/solar_terms.py new file mode 100644 index 0000000..e2d5b51 --- /dev/null +++ b/saju-lab/app/calculator/solar_terms.py @@ -0,0 +1,130 @@ +"""24절기 + sxtwl 기반 月支 계산. + +sxtwl(寿星天文历) 2.x의 JieQi index 순서: + 0=冬至, 1=小寒, 2=大寒, 3=立春, 4=雨水, 5=驚蟄, + 6=春分, 7=清明, 8=穀雨, 9=立夏, 10=小滿, 11=芒種, + 12=夏至, 13=小暑, 14=大暑, 15=立秋, 16=處暑, 17=白露, + 18=秋分, 19=寒露, 20=霜降, 21=立冬, 22=小雪, 23=大雪 + +(冬至부터 15° 간격으로 황경 증가 — sxtwl.cpp 의 `xn % 24` 계산식 기준) + +saju에서 月支는 12개의 '절'(節, 홀수 황경 인덱스에 해당)을 기준으로 12구간으로 나눈다: + 立春(3) → 寅(2) + 驚蟄(5) → 卯(3) + 清明(7) → 辰(4) + 立夏(9) → 巳(5) + 芒種(11) → 午(6) + 小暑(13) → 未(7) + 立秋(15) → 申(8) + 白露(17) → 酉(9) + 寒露(19) → 戌(10) + 立冬(21) → 亥(11) + 大雪(23) → 子(0) + 小寒(1) → 丑(1) +""" +from __future__ import annotations + +from datetime import date as _date +from functools import lru_cache + +import sxtwl + + +# sxtwl JieQi index → 月支 인덱스 (子=0, 丑=1, 寅=2, ..., 亥=11) +# 12 '節' 만 매핑 (홀수 인덱스), '氣' (짝수 인덱스)는 月支 경계가 아님 → 직전 절의 月支 유지 +JIEQI_TO_BRANCH: dict[int, int] = { + 3: 2, # 立春 → 寅 + 5: 3, # 驚蟄 → 卯 + 7: 4, # 清明 → 辰 + 9: 5, # 立夏 → 巳 + 11: 6, # 芒種 → 午 + 13: 7, # 小暑 → 未 + 15: 8, # 立秋 → 申 + 17: 9, # 白露 → 酉 + 19: 10, # 寒露 → 戌 + 21: 11, # 立冬 → 亥 + 23: 0, # 大雪 → 子 + 1: 1, # 小寒 → 丑 +} + + +@lru_cache(maxsize=64) +def _all_jieqi_dates(year: int) -> tuple[tuple[_date, int], ...]: + """주어진 연도의 24절기 (date, jieqi_index) 튜플 리스트. + + sxtwl.getJieQiByYear(year) → list[JieQiInfo(jd, jqIndex)] 사용. + sxtwl.JD2DD(jd) → Time(Y, M, D, ...)로 양력 변환. + + @lru_cache로 같은 연도 반복 호출 시 sxtwl 재호출 방지. + """ + result: list[tuple[_date, int]] = [] + jq_list = sxtwl.getJieQiByYear(year) + for info in jq_list: + t = sxtwl.JD2DD(info.jd) + try: + d = _date(int(t.Y), int(t.M), int(t.D)) + except (ValueError, OverflowError): + continue + result.append((d, int(info.jqIndex))) + return tuple(result) + + +def get_current_solar_term(year: int, month: int, day: int) -> int: + """현재 날짜에 적용되는 절기 인덱스 (0~23, sxtwl 기준) 반환. + + 입력 날짜 이전(또는 같은) 가장 가까운 절기의 인덱스를 반환. + 절기가 아직 들어오지 않은 경우(연초 등) ±1년치를 함께 탐색. + """ + target = _date(year, month, day) + candidates: list[tuple[_date, int]] = [] + for y in (year - 1, year, year + 1): + candidates.extend(_all_jieqi_dates(y)) + candidates.sort() + + last_idx = 0 # 폴백 + for d, idx in candidates: + if d <= target: + last_idx = idx + else: + break + return last_idx + + +def get_solar_term_month_branch(year: int, month: int, day: int) -> int: + """현재 날짜의 月支 인덱스 (0~11, 子=0...亥=11). 절(節) 기준 12구간. + + 12개 '절'만 추출해, 입력 날짜 이전 가장 가까운 절의 月支를 반환. + 立春 이전이면 직전 해 小寒(=丑) 또는 大雪(=子) 기준. + """ + target = _date(year, month, day) + jie_dates: list[tuple[_date, int]] = [] + for y in (year - 1, year, year + 1): + for d, qi_idx in _all_jieqi_dates(y): + if qi_idx in JIEQI_TO_BRANCH: + jie_dates.append((d, JIEQI_TO_BRANCH[qi_idx])) + jie_dates.sort() + + last_branch = 1 # 立春 이전 폴백 — 丑(축) + for d, branch_idx in jie_dates: + if d <= target: + last_branch = branch_idx + else: + break + return last_branch + + +def get_days_to_next_solar_term(year: int, month: int, day: int) -> int: + """다음 절기까지 남은 일수 (대운 계산용). + + 24절기 모두 대상. 입력 날짜 이후 가장 가까운 절기까지의 일 수 반환. + """ + target = _date(year, month, day) + all_qi: list[tuple[_date, int]] = [] + for y in (year, year + 1): + all_qi.extend(_all_jieqi_dates(y)) + all_qi.sort() + + for d, _idx in all_qi: + if d > target: + return (d - target).days + return 30 # 폴백 (이론상 도달 불가) diff --git a/saju-lab/tests/test_solar_terms.py b/saju-lab/tests/test_solar_terms.py new file mode 100644 index 0000000..3f99035 --- /dev/null +++ b/saju-lab/tests/test_solar_terms.py @@ -0,0 +1,129 @@ +"""solar_terms.py — sxtwl 기반 24절기 + 月支 매핑 테스트. + +⚠️ Reference fixture(`reference_saju.json`)의 month branch는 모두 "丑"으로 동일하다. +이는 saju-web의 `solar-terms.ts`가 tsx 런타임에서 `require('solarlunar')`를 잡지 못해 +fallback(`return 23`, 大寒)으로 떨어지고, 大寒 → 丑 매핑이 적용된 결과로 보인다. +따라서 reference fixture는 절기 검증에 사용할 수 없으며, 본 테스트는 +- 절기 boundary (입춘 day) 동작 검증 +- sxtwl JieQi index 순서 가정 검증 +- 月支 자체 일관성(여러 날짜에서 기대되는 月支 산출) 검증 +세 가지로 구성한다. +""" +import json +from pathlib import Path + +import pytest + +from app.calculator import solar_terms as st + + +REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json" +REF = json.loads(REF_PATH.read_text(encoding="utf-8")) + +BRANCHES = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"] + + +def test_jieqi_index_order_lichun_is_3(): + """sxtwl JieQi index에서 立春은 3(冬至=0 기준 황경 +45°). + + 1995-02-04는 입춘 당일. sxtwl이 그 날짜에 jqIndex=3을 반환해야 함. + """ + import sxtwl + + qi_list = sxtwl.getJieQiByYear(1995) + # 1995-02-04 ±1일 안에 jqIndex=3인 항목이 있어야 함 + found = False + for info in qi_list: + t = sxtwl.JD2DD(info.jd) + if int(info.jqIndex) == 3 and int(t.Y) == 1995 and int(t.M) == 2 and 3 <= int(t.D) <= 5: + found = True + break + assert found, "1995년 立春(jqIndex=3) 절기가 2/3~2/5 사이에서 발견되지 않음" + + +def test_get_current_solar_term_ipchun_boundary(): + """1995-02-04는 입춘 당일.""" + idx_4 = st.get_current_solar_term(1995, 2, 4) + idx_5 = st.get_current_solar_term(1995, 2, 5) + idx_3 = st.get_current_solar_term(1995, 2, 3) + # 2/4와 2/5는 같은 절기 인덱스 (立春=3) + assert idx_4 == idx_5 + # 2/3은 다른 인덱스 (立春 이전 — 大寒=2) + assert idx_4 != idx_3 + + +def test_get_solar_term_month_branch_ipchun_day(): + """1995-02-04는 입춘 → 月支 = 寅(인덱스 2).""" + branch_idx = st.get_solar_term_month_branch(1995, 2, 4) + assert BRANCHES[branch_idx] == "寅" + + +def test_get_solar_term_month_branch_before_ipchun(): + """1995-02-03은 입춘 이전 → 月支 = 丑(인덱스 1).""" + branch_idx = st.get_solar_term_month_branch(1995, 2, 3) + assert BRANCHES[branch_idx] == "丑" + + +@pytest.mark.parametrize( + "year,month,day,expected_branch", + [ + # 표준 절기 경계 기준 月支 검증 (saju 일반 상식) + (1990, 5, 15, "巳"), # 立夏(5/6) ~ 芒種(6/6) 사이 → 巳월 + (1990, 6, 6, "午"), # 芒種 당일 → 午월 + (1980, 6, 6, "午"), # 芒種 ~ 小暑 → 午월 + (1995, 2, 5, "寅"), # 立春 다음날 → 寅 + (1995, 2, 3, "丑"), # 立春 이전 → 丑 + (2000, 2, 29, "寅"), # 立春 이후 → 寅 + (2010, 12, 31, "子"), # 大雪 이후 → 子 + (1985, 1, 1, "子"), # 1/1은 小寒(1/5) 이전 → 子 + (1985, 1, 20, "丑"), # 小寒 이후 → 丑 + (1972, 7, 24, "未"), # 小暑 ~ 立秋 사이 → 未 + (1992, 8, 8, "申"), # 立秋 ~ 白露 → 申 + (1988, 9, 9, "酉"), # 白露 ~ 寒露 → 酉 + (2005, 6, 22, "午"), # 芒種 ~ 小暑 → 午 + (2020, 9, 23, "酉"), # 白露 ~ 寒露 → 酉 + ], +) +def test_month_branch_standard_cases(year, month, day, expected_branch): + """일반 사주 상식 기준 月支 검증 — sxtwl 절기와 일치해야 함.""" + branch_idx = st.get_solar_term_month_branch(year, month, day) + actual = BRANCHES[branch_idx] + assert actual == expected_branch, ( + f"{year}-{month:02d}-{day:02d}: expected {expected_branch}, got {actual}" + ) + + +def test_get_days_to_next_solar_term_positive(): + """다음 절기까지 일수는 항상 양수, 16일 이내.""" + # 立春(2/4) 다음날 → 雨水(2/19)까지 약 15일 + days = st.get_days_to_next_solar_term(1995, 2, 5) + assert 1 <= days <= 16, f"days = {days} (expected 1~16)" + + +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_month_branch_matches_reference(case): + """Reference fixture의 month branch와 sxtwl 기반 절기 계산 일치. + + ⚠️ 알려진 이슈: reference_saju.json의 모든 30 케이스 month branch가 "丑"으로 동일하다. + 이는 saju-web `solar-terms.ts`가 tsx 런타임에서 solarlunar require 실패 → + `return 23` 폴백 → 大寒 → 丑으로 떨어진 reference 측 버그. + + 본 테스트는 BUGGY reference와 일치하는지 검증하기 위한 것이 아니라, + sxtwl 기반 계산이 reference와 자연스럽게 어긋남을 명시적으로 기록하기 위해 xfail로 마킹. + """ + inp = case["input"] + expected_branch = case["expected"]["saju"]["month"]["branch"] + branch_idx = st.get_solar_term_month_branch(inp["year"], inp["month"], inp["day"]) + actual_branch = BRANCHES[branch_idx] + if expected_branch == "丑": + pytest.xfail( + f"reference_saju.json buggy: 모든 케이스 month='丑'으로 고정. " + f"sxtwl 정답은 {actual_branch} (input={inp})" + ) + assert actual_branch == expected_branch, ( + f"mismatch for {inp}: got {actual_branch}, expected {expected_branch}" + )