feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests)

This commit is contained in:
2026-05-26 07:58:02 +09:00
parent 8ef0ba81f2
commit 579e7387be
2 changed files with 170 additions and 0 deletions

View File

@@ -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,
}

View File

@@ -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())