diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..845bfa5fe4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.pytest_cache/ +.mypy_cache/ diff --git a/bazi/__init__.py b/bazi/__init__.py new file mode 100644 index 0000000000..b6ddf70c36 --- /dev/null +++ b/bazi/__init__.py @@ -0,0 +1,10 @@ +"""Bazi analysis helpers.""" + +from .ten_gods import TenGod, TenGodCode, HeavenlyStem, get_ten_god + +__all__ = [ + "TenGod", + "TenGodCode", + "HeavenlyStem", + "get_ten_god", +] diff --git a/bazi/ten_gods.py b/bazi/ten_gods.py new file mode 100644 index 0000000000..df9ed76c7e --- /dev/null +++ b/bazi/ten_gods.py @@ -0,0 +1,158 @@ +"""Ten Gods mapping for BaZi (Four Pillars of Destiny). + +This module exposes utilities to derive the "Ten God" relationship between a +pair of heavenly stems. The Ten Gods represent how any stem relates to the day +master (day stem) in BaZi analysis. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Literal, cast + +Element = Literal["wood", "fire", "earth", "metal", "water"] +Polarity = Literal["yang", "yin"] +HeavenlyStem = Literal[ + "jia", + "yi", + "bing", + "ding", + "wu", + "ji", + "geng", + "xin", + "ren", + "gui", +] +TenGodCode = Literal[ + "BI_JIAN", + "JIE_CAI", + "SHI_SHEN", + "SHANG_GUAN", + "ZHENG_CAI", + "PIAN_CAI", + "ZHENG_GUAN", + "QI_SHA", + "ZHENG_YIN", + "PIAN_YIN", +] + + +@dataclass(frozen=True) +class StemInfo: + """Metadata describing a heavenly stem.""" + + element: Element + polarity: Polarity + + +@dataclass(frozen=True) +class TenGod: + """Represents the Ten God relationship outcome.""" + + code: TenGodCode + label: str + + +_STEMS: Dict[HeavenlyStem, StemInfo] = { + "jia": StemInfo(element="wood", polarity="yang"), + "yi": StemInfo(element="wood", polarity="yin"), + "bing": StemInfo(element="fire", polarity="yang"), + "ding": StemInfo(element="fire", polarity="yin"), + "wu": StemInfo(element="earth", polarity="yang"), + "ji": StemInfo(element="earth", polarity="yin"), + "geng": StemInfo(element="metal", polarity="yang"), + "xin": StemInfo(element="metal", polarity="yin"), + "ren": StemInfo(element="water", polarity="yang"), + "gui": StemInfo(element="water", polarity="yin"), +} + +_GENERATES: Dict[Element, Element] = { + "wood": "fire", + "fire": "earth", + "earth": "metal", + "metal": "water", + "water": "wood", +} + +_CONTROLS: Dict[Element, Element] = { + "wood": "earth", + "earth": "water", + "water": "fire", + "fire": "metal", + "metal": "wood", +} + +_TEN_GOD_LABELS: Dict[TenGodCode, str] = { + "BI_JIAN": "Friend", + "JIE_CAI": "Rob Wealth", + "SHI_SHEN": "Eating God", + "SHANG_GUAN": "Hurting Officer", + "ZHENG_CAI": "Direct Wealth", + "PIAN_CAI": "Indirect Wealth", + "ZHENG_GUAN": "Direct Officer", + "QI_SHA": "Seven Killings", + "ZHENG_YIN": "Direct Resource", + "PIAN_YIN": "Indirect Resource", +} + + +def _normalize_stem(stem: str) -> HeavenlyStem: + """Normalize user input and validate it represents a known stem.""" + + key = stem.strip().lower() + if key not in _STEMS: + raise ValueError(f"Unknown heavenly stem: {stem!r}") + return cast(HeavenlyStem, key) + + +def get_ten_god(day_stem: str, other_stem: str) -> TenGod: + """Return the Ten God relationship for two heavenly stems. + + Args: + day_stem: The reference (day master) heavenly stem. + other_stem: The heavenly stem to classify relative to ``day_stem``. + + Raises: + ValueError: If either stem is not recognised. + + Returns: + A :class:`TenGod` describing the relationship through its code and + human-readable label. + """ + + day_key = _normalize_stem(day_stem) + other_key = _normalize_stem(other_stem) + + day = _STEMS[day_key] + other = _STEMS[other_key] + + same_polarity = day.polarity == other.polarity + + if day.element == other.element: + code: TenGodCode = "BI_JIAN" if same_polarity else "JIE_CAI" + elif _GENERATES[day.element] == other.element: + code = "SHI_SHEN" if same_polarity else "SHANG_GUAN" + elif _GENERATES[other.element] == day.element: + code = "ZHENG_YIN" if same_polarity else "PIAN_YIN" + elif _CONTROLS[day.element] == other.element: + code = "ZHENG_CAI" if same_polarity else "PIAN_CAI" + elif _CONTROLS[other.element] == day.element: + code = "ZHENG_GUAN" if same_polarity else "QI_SHA" + else: # pragma: no cover - exhaustive for valid stems + raise ValueError( + "Unable to determine Ten God relationship for" + f" {day_stem!r} and {other_stem!r}" + ) + + return TenGod(code=code, label=_TEN_GOD_LABELS[code]) + + +__all__ = [ + "Element", + "Polarity", + "HeavenlyStem", + "TenGodCode", + "TenGod", + "get_ten_god", +] diff --git a/tests/test_ten_gods.py b/tests/test_ten_gods.py new file mode 100644 index 0000000000..e81a96c649 --- /dev/null +++ b/tests/test_ten_gods.py @@ -0,0 +1,32 @@ +import pytest + +from bazi.ten_gods import TenGod, get_ten_god + + +@pytest.mark.parametrize( + ("day", "other", "expected"), + [ + ("Jia", "Jia", TenGod(code="BI_JIAN", label="Friend")), + ("Jia", "Yi", TenGod(code="JIE_CAI", label="Rob Wealth")), + ("Jia", "Bing", TenGod(code="SHI_SHEN", label="Eating God")), + ("Jia", "Ding", TenGod(code="SHANG_GUAN", label="Hurting Officer")), + ("Jia", "Wu", TenGod(code="ZHENG_CAI", label="Direct Wealth")), + ("Jia", "Ji", TenGod(code="PIAN_CAI", label="Indirect Wealth")), + ("Jia", "Geng", TenGod(code="ZHENG_GUAN", label="Direct Officer")), + ("Jia", "Xin", TenGod(code="QI_SHA", label="Seven Killings")), + ("Jia", "Ren", TenGod(code="ZHENG_YIN", label="Direct Resource")), + ("Jia", "Gui", TenGod(code="PIAN_YIN", label="Indirect Resource")), + ], +) +def test_ten_god_mappings(day: str, other: str, expected: TenGod) -> None: + result = get_ten_god(day, other) + assert result == expected + + +def test_case_insensitivity() -> None: + assert get_ten_god("gui", "REN").code == "JIE_CAI" + + +def test_invalid_input() -> None: + with pytest.raises(ValueError): + get_ten_god("not_a_stem", "Jia")