Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.pytest_cache/
.mypy_cache/
10 changes: 10 additions & 0 deletions bazi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Bazi analysis helpers."""

from .ten_gods import TenGod, TenGodCode, HeavenlyStem, get_ten_god

__all__ = [
"TenGod",
"TenGodCode",
"HeavenlyStem",
"get_ten_god",
]
158 changes: 158 additions & 0 deletions bazi/ten_gods.py
Original file line number Diff line number Diff line change
@@ -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",
]
32 changes: 32 additions & 0 deletions tests/test_ten_gods.py
Original file line number Diff line number Diff line change
@@ -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")