From 579e7387bee78d5e42857b1868ae651c36f73015 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 26 May 2026 07:58:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(saju-lab):=20fortune=5Fscores.py=20?= =?UTF-8?q?=E2=80=94=204=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=90=EC=88=98=20+=20overall=20(6=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- saju-lab/app/calculator/fortune_scores.py | 114 ++++++++++++++++++++++ saju-lab/tests/test_fortune_scores.py | 56 +++++++++++ 2 files changed, 170 insertions(+) create mode 100644 saju-lab/app/calculator/fortune_scores.py create mode 100644 saju-lab/tests/test_fortune_scores.py diff --git a/saju-lab/app/calculator/fortune_scores.py b/saju-lab/app/calculator/fortune_scores.py new file mode 100644 index 0000000..02d8d2b --- /dev/null +++ b/saju-lab/app/calculator/fortune_scores.py @@ -0,0 +1,114 @@ +"""4 카테고리 점수 — 재물/연애/인간관계/직장 + 종합점.""" +from typing import Dict + +from .constants import ( + FIVE_ELEMENTS, IS_YANG_STEM, SHENG_CYCLE, KE_CYCLE, +) + + +def _ten_god_counts(saju: dict) -> Dict[str, int]: + """4기둥의 ten_god 카운트.""" + counts: Dict[str, int] = {} + for p in ("year", "month", "day", "hour"): + pillar = saju.get(p) + if not pillar: + continue + tg = pillar.get("ten_god", "") + counts[tg] = counts.get(tg, 0) + 1 + return counts + + +def _branch_has_chong(saju: dict) -> bool: + """일지가 다른 기둥과 6충 관계인지.""" + LIU_CHONG = { + frozenset(["子", "午"]), frozenset(["丑", "未"]), + frozenset(["寅", "申"]), frozenset(["卯", "酉"]), + frozenset(["辰", "戌"]), frozenset(["巳", "亥"]), + } + day_branch = saju["day"]["branch"] + for p in ("year", "month", "hour"): + pillar = saju.get(p) + if not pillar: + continue + if frozenset([day_branch, pillar["branch"]]) in LIU_CHONG: + return True + return False + + +def _branch_has_he(saju: dict) -> bool: + """일지가 다른 기둥과 6합 관계인지.""" + LIU_HE = { + frozenset(["子", "丑"]), frozenset(["寅", "亥"]), + frozenset(["卯", "戌"]), frozenset(["辰", "酉"]), + frozenset(["巳", "申"]), frozenset(["午", "未"]), + } + day_branch = saju["day"]["branch"] + for p in ("year", "month", "hour"): + pillar = saju.get(p) + if not pillar: + continue + if frozenset([day_branch, pillar["branch"]]) in LIU_HE: + return True + return False + + +def _clamp(v: int) -> int: + return max(0, min(100, v)) + + +def calculate_fortune_scores(saju: dict, analysis: dict, current_year: int) -> Dict[str, int]: + """4 카테고리 + overall (가중평균).""" + counts = _ten_god_counts(saju) + has_chong = _branch_has_chong(saju) + has_he = _branch_has_he(saju) + + strength = analysis.get("day_master_strength", {}).get("result", "중화") + + # 재물운: 정재/편재 강도 + wealth = 60 + wealth += counts.get("정재", 0) * 8 + wealth += counts.get("편재", 0) * 6 + wealth += counts.get("식신", 0) * 4 + wealth -= counts.get("비견", 0) * 5 + wealth -= counts.get("겁재", 0) * 5 + wealth = _clamp(wealth) + + # 연애운: 일지 합/충 + 정관/정재 + romance = 60 + if has_he: + romance += 15 + if has_chong: + romance -= 15 + romance += counts.get("정관", 0) * 5 + romance += counts.get("정재", 0) * 5 + romance = _clamp(romance) + + # 인간관계: 인성 + 비겁 적정 + 식상 + social = 60 + social += counts.get("정인", 0) * 5 + social += counts.get("편인", 0) * 4 + social += min(counts.get("비견", 0), 2) * 5 + social += counts.get("식신", 0) * 3 + social += counts.get("상관", 0) * 3 + social = _clamp(social) + + # 직장운: 정관 + 편관(제어) + 일간 강도 + career = 60 + career += counts.get("정관", 0) * 10 + career += counts.get("편관", 0) * 5 + if strength == "신강": + career += 8 + elif strength == "신약": + career -= 5 + career = _clamp(career) + + overall = round(wealth * 0.3 + career * 0.3 + romance * 0.2 + social * 0.2) + overall = _clamp(overall) + + return { + "wealth": wealth, + "romance": romance, + "social": social, + "career": career, + "overall": overall, + } diff --git a/saju-lab/tests/test_fortune_scores.py b/saju-lab/tests/test_fortune_scores.py new file mode 100644 index 0000000..f1096f9 --- /dev/null +++ b/saju-lab/tests/test_fortune_scores.py @@ -0,0 +1,56 @@ +import json +from pathlib import Path +import pytest + +from app.calculator.core import calculate_saju +from app.calculator.analysis import perform_full_analysis +from app.calculator.fortune_scores import calculate_fortune_scores + + +def _saju_for(year, month, day, hour, gender): + saju = calculate_saju(year, month, day, hour, gender) + analysis = perform_full_analysis(saju, 2026) + return saju, analysis + + +def test_scores_in_valid_range(): + saju, analysis = _saju_for(1990, 5, 15, 14, "male") + result = calculate_fortune_scores(saju, analysis, 2026) + for key in ("wealth", "romance", "social", "career", "overall"): + assert key in result, f"missing key: {key}" + assert 0 <= result[key] <= 100, f"{key}={result[key]} out of range" + + +def test_overall_weighted_average(): + saju, analysis = _saju_for(1990, 5, 15, 14, "male") + r = calculate_fortune_scores(saju, analysis, 2026) + expected = round( + r["wealth"] * 0.3 + r["career"] * 0.3 + r["romance"] * 0.2 + r["social"] * 0.2 + ) + assert abs(r["overall"] - expected) <= 1, f"overall mismatch: {r['overall']} vs {expected}" + + +def test_clamping_lower_bound(): + saju, analysis = _saju_for(2000, 2, 29, 12, "male") + r = calculate_fortune_scores(saju, analysis, 2026) + assert all(v >= 0 for v in r.values()) + + +def test_clamping_upper_bound(): + saju, analysis = _saju_for(1985, 1, 1, 0, "female") + r = calculate_fortune_scores(saju, analysis, 2026) + assert all(v <= 100 for v in r.values()) + + +def test_different_inputs_different_scores(): + s1, a1 = _saju_for(1990, 5, 15, 14, "male") + s2, a2 = _saju_for(1985, 1, 1, 0, "female") + r1 = calculate_fortune_scores(s1, a1, 2026) + r2 = calculate_fortune_scores(s2, a2, 2026) + assert r1 != r2, "scores should differ for different sajus" + + +def test_handles_missing_hour(): + saju, analysis = _saju_for(1990, 5, 15, None, "male") + r = calculate_fortune_scores(saju, analysis, 2026) + assert all(0 <= v <= 100 for v in r.values())