feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests)
This commit is contained in:
114
saju-lab/app/calculator/fortune_scores.py
Normal file
114
saju-lab/app/calculator/fortune_scores.py
Normal 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,
|
||||
}
|
||||
56
saju-lab/tests/test_fortune_scores.py
Normal file
56
saju-lab/tests/test_fortune_scores.py
Normal 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())
|
||||
Reference in New Issue
Block a user