diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..7e64d2be1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +.pytest_cache/ diff --git a/README.md b/README.md index a1f92f5862..ee4f55622e 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ -See you later \ No newline at end of file +# Calendar Adapter + +This project provides a small Python package that wraps the +[lunar-python](https://pypi.org/project/lunar-python/) ephemeris in a normalized +``CalendarAdapter`` facade. The adapter derives BaZi pillars while applying +solar-term new year (节气换年) rules and handling the ``晚子时`` rollover so that the +late Zi hour advances the day pillar as expected. + +## Getting started + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +pytest +``` + +## Example + +```python +from datetime import datetime +from calendar_adapter import CalendarAdapter + +adapter = CalendarAdapter() +result = adapter.compute_bazi(datetime(2024, 2, 4, 23, 45)) + +print(result.text) # e.g. "甲辰癸卯甲子辛亥" +print(result.metadata.to_dict()) # structured metadata with solar-term context +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..5d32968884 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "calendar-adapter" +version = "0.1.0" +description = "Calendar adapter that derives BaZi pillars using lunar-python." +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "cto.new", email = "engineering@cto.new" }] +dependencies = [ + "lunar-python>=1.4,<2.0", +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/src/calendar_adapter/__init__.py b/src/calendar_adapter/__init__.py new file mode 100644 index 0000000000..1dfaec0cd7 --- /dev/null +++ b/src/calendar_adapter/__init__.py @@ -0,0 +1,19 @@ +"""Calendar adapter facade around :mod:`lunar_python` BaZi utilities.""" + +from .adapter import ( + BaZiMetadata, + BaZiResult, + CalendarAdapter, + Pillar, + SolarTermBoundary, + SolarTermInfo, +) + +__all__ = [ + "BaZiMetadata", + "BaZiResult", + "CalendarAdapter", + "Pillar", + "SolarTermBoundary", + "SolarTermInfo", +] diff --git a/src/calendar_adapter/adapter.py b/src/calendar_adapter/adapter.py new file mode 100644 index 0000000000..fb78e12634 --- /dev/null +++ b/src/calendar_adapter/adapter.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import importlib.metadata +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, Optional + +from zoneinfo import ZoneInfo + +from lunar_python import JieQi, Lunar, Solar + +_DEFAULT_TIMEZONE = ZoneInfo("Asia/Shanghai") + + +@dataclass(frozen=True) +class Pillar: + """Normalized representation of a single Heavenly Stem / Earthly Branch pair.""" + + stem: str + branch: str + + @property + def text(self) -> str: + """Concatenated representation of the pillar, e.g. ``癸卯``.""" + + return f"{self.stem}{self.branch}" + + def to_dict(self) -> Dict[str, str]: + """Serialize the pillar into a dictionary structure.""" + + return { + "stem": self.stem, + "branch": self.branch, + "text": self.text, + } + + +@dataclass(frozen=True) +class SolarTermInfo: + """Snapshot of a solar term occurrence.""" + + name: str + moment: datetime + + def to_dict(self) -> Dict[str, str]: + return { + "name": self.name, + "moment": self.moment.isoformat(), + } + + +@dataclass(frozen=True) +class SolarTermBoundary: + """Describes how a solar term boundary (e.g. 立春) impacts the calculation.""" + + name: str + moment: datetime + applied_year: int + has_passed: bool + + def to_dict(self) -> Dict[str, object]: + return { + "name": self.name, + "moment": self.moment.isoformat(), + "applied_year": self.applied_year, + "has_passed": self.has_passed, + } + + +@dataclass(frozen=True) +class BaZiMetadata: + """Additional context returned alongside the BaZi pillars.""" + + solar_moment: datetime + timezone: str + solar_term_year: int + solar_term_year_shifted: bool + solar_term_boundary: SolarTermBoundary + current_solar_term: Optional[SolarTermInfo] + next_solar_term: Optional[SolarTermInfo] + late_zi_hour_applied: bool + sect: int + library: str + library_version: str + lunar_year: int + lunar_month: int + lunar_day: int + is_leap_month: bool + + def to_dict(self) -> Dict[str, object]: + return { + "solar": { + "moment": self.solar_moment.isoformat(), + "timezone": self.timezone, + }, + "lunar": { + "year": self.lunar_year, + "month": self.lunar_month, + "day": self.lunar_day, + "is_leap_month": self.is_leap_month, + }, + "solar_term_year": self.solar_term_year, + "solar_term_year_shifted": self.solar_term_year_shifted, + "solar_terms": { + "year_boundary": self.solar_term_boundary.to_dict(), + "current": None if self.current_solar_term is None else self.current_solar_term.to_dict(), + "next": None if self.next_solar_term is None else self.next_solar_term.to_dict(), + }, + "late_zi_hour_applied": self.late_zi_hour_applied, + "sect": self.sect, + "source": { + "library": self.library, + "version": self.library_version, + }, + } + + +@dataclass(frozen=True) +class BaZiResult: + """Container for the computed BaZi pillars and contextual metadata.""" + + pillars: Dict[str, Pillar] + metadata: BaZiMetadata + + @property + def text(self) -> str: + """Return the canonical four-pillar string (year→hour).""" + + order = ("year", "month", "day", "hour") + return "".join(self.pillars[key].text for key in order if key in self.pillars) + + def to_dict(self) -> Dict[str, object]: + """Serialize the result to a JSON-friendly structure.""" + + return { + "pillars": {key: pillar.to_dict() for key, pillar in self.pillars.items()}, + "metadata": self.metadata.to_dict(), + "text": self.text, + } + + +class CalendarAdapter: + """High-level facade for producing BaZi pillars via :mod:`lunar_python`.""" + + def __init__(self, timezone: str | ZoneInfo = _DEFAULT_TIMEZONE) -> None: + if isinstance(timezone, str): + timezone = ZoneInfo(timezone) + self._timezone: ZoneInfo = timezone + self._library_version = importlib.metadata.version("lunar-python") + + def compute_bazi(self, moment: datetime, sect: int = 1) -> BaZiResult: + """Compute the BaZi pillars for a moment in time. + + Args: + moment: Datetime to evaluate. Naive datetimes are assumed to already be + expressed in the adapter's timezone. + sect: 八字流派 selection. ``1`` applies the traditional late 子时 rollover + that advances the day pillar after midnight, ``2`` keeps the day pillar + anchored to the previous day. + + Returns: + :class:`BaZiResult` containing normalized pillars and metadata. + """ + + if sect not in (1, 2): + raise ValueError("sect must be either 1 or 2") + + local_moment = self._ensure_timezone(moment) + solar = Solar.fromYmdHms( + local_moment.year, + local_moment.month, + local_moment.day, + local_moment.hour, + local_moment.minute, + local_moment.second, + ) + lunar = Lunar.fromSolar(solar) + eight_char = lunar.getEightChar() + eight_char.setSect(sect) + + pillars = { + "year": Pillar(eight_char.getYearGan(), eight_char.getYearZhi()), + "month": Pillar(eight_char.getMonthGan(), eight_char.getMonthZhi()), + "day": Pillar(eight_char.getDayGan(), eight_char.getDayZhi()), + "hour": Pillar(eight_char.getTimeGan(), eight_char.getTimeZhi()), + } + + li_chun_solar = lunar.getJieQiTable()["立春"] + li_chun_moment = self._solar_to_datetime(li_chun_solar) + has_passed_li_chun = local_moment >= li_chun_moment + solar_term_year = ( + li_chun_moment.year if has_passed_li_chun else li_chun_moment.year - 1 + ) + solar_term_year_shifted = solar_term_year != local_moment.year + + metadata = BaZiMetadata( + solar_moment=local_moment, + timezone=self._timezone_key, + solar_term_year=solar_term_year, + solar_term_year_shifted=solar_term_year_shifted, + solar_term_boundary=SolarTermBoundary( + name="立春", + moment=li_chun_moment, + applied_year=solar_term_year, + has_passed=has_passed_li_chun, + ), + current_solar_term=self._jieqi_to_info(lunar.getPrevJie()), + next_solar_term=self._jieqi_to_info(lunar.getNextJie()), + late_zi_hour_applied=self._is_late_zi_hour(local_moment, sect), + sect=sect, + library="lunar_python", + library_version=self._library_version, + lunar_year=lunar.getYear(), + lunar_month=abs(lunar.getMonth()), + lunar_day=lunar.getDay(), + is_leap_month=lunar.getMonth() < 0, + ) + + return BaZiResult(pillars=pillars, metadata=metadata) + + def _ensure_timezone(self, moment: datetime) -> datetime: + if moment.tzinfo is None: + return moment.replace(tzinfo=self._timezone) + return moment.astimezone(self._timezone) + + @property + def _timezone_key(self) -> str: + return getattr(self._timezone, "key", str(self._timezone)) + + def _solar_to_datetime(self, solar: Solar) -> datetime: + return datetime( + solar.getYear(), + solar.getMonth(), + solar.getDay(), + solar.getHour(), + solar.getMinute(), + solar.getSecond(), + tzinfo=self._timezone, + ) + + def _jieqi_to_info(self, jieqi: Optional[JieQi]) -> Optional[SolarTermInfo]: + if jieqi is None: + return None + solar = jieqi.getSolar() + return SolarTermInfo(name=jieqi.getName(), moment=self._solar_to_datetime(solar)) + + @staticmethod + def _is_late_zi_hour(moment: datetime, sect: int) -> bool: + """Determine whether the supplied moment falls into the late 子时 window. + + Sect 1 advances the day pillar once the clock passes midnight, while sect 2 keeps + the day pillar anchored to the preceding date. We flag the late 子时 window when the + hour is between 00:00 and 00:59 inclusive for sect 1, or when the flow switches at + 23:00 for sect 2. + """ + + if sect == 1: + return moment.hour == 0 + # Sect 2 keeps the day pillar anchored, so we consider 23:00-23:59 as the rollover + # window for completeness. + return moment.hour == 23 diff --git a/tests/test_calendar_adapter.py b/tests/test_calendar_adapter.py new file mode 100644 index 0000000000..9ebc4ccc1b --- /dev/null +++ b/tests/test_calendar_adapter.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import datetime + +from zoneinfo import ZoneInfo + +from calendar_adapter import CalendarAdapter + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +def test_solar_term_boundary_switches_year_pillar() -> None: + adapter = CalendarAdapter() + + before_li_chun = datetime(2023, 2, 4, 9, 0, tzinfo=SHANGHAI) + after_li_chun = datetime(2023, 2, 4, 11, 0, tzinfo=SHANGHAI) + + result_before = adapter.compute_bazi(before_li_chun) + result_after = adapter.compute_bazi(after_li_chun) + + assert result_before.pillars["year"].text == "壬寅" + assert result_after.pillars["year"].text == "癸卯" + + assert result_before.metadata.solar_term_year == 2022 + assert result_after.metadata.solar_term_year == 2023 + + assert result_before.metadata.solar_term_year_shifted is True + assert result_after.metadata.solar_term_year_shifted is False + + boundary = result_before.metadata.solar_term_boundary + assert boundary.name == "立春" + assert boundary.moment.hour == 10 + assert boundary.moment.minute == 42 + assert boundary.applied_year == 2022 + assert boundary.has_passed is False + + current_term = result_before.metadata.current_solar_term + assert current_term is not None + assert current_term.name == "小寒" + + next_term = result_before.metadata.next_solar_term + assert next_term is not None + assert next_term.name == "立春" + + # After crossing the boundary we should be inside 立春 and looking ahead to 惊蛰 + current_term_after = result_after.metadata.current_solar_term + assert current_term_after is not None + assert current_term_after.name == "立春" + next_term_after = result_after.metadata.next_solar_term + assert next_term_after is not None + assert next_term_after.name == "惊蛰" + + +def test_late_zi_hour_advances_day_pillar_and_sets_flag() -> None: + adapter = CalendarAdapter() + + before_midnight = datetime(2023, 2, 3, 22, 30, tzinfo=SHANGHAI) + late_zi = datetime(2023, 2, 4, 0, 30, tzinfo=SHANGHAI) + sect_transition = datetime(2023, 2, 3, 23, 30, tzinfo=SHANGHAI) + + before_result = adapter.compute_bazi(before_midnight) + late_result = adapter.compute_bazi(late_zi) + + assert before_result.pillars["day"].text == "壬辰" + assert late_result.pillars["day"].text == "癸巳" + + assert before_result.metadata.late_zi_hour_applied is False + assert late_result.metadata.late_zi_hour_applied is True + + sect_one_result = adapter.compute_bazi(sect_transition, sect=1) + sect_two_result = adapter.compute_bazi(sect_transition, sect=2) + assert sect_one_result.pillars["day"].text == "癸巳" + assert sect_two_result.pillars["day"].text == "壬辰" + assert sect_one_result.metadata.late_zi_hour_applied is False + assert sect_two_result.metadata.late_zi_hour_applied is True + + assert late_result.metadata.solar_term_year == 2022 + assert late_result.metadata.solar_term_boundary.has_passed is False + + +def test_serialization_outputs_normalized_payload() -> None: + adapter = CalendarAdapter() + sample = datetime(2024, 2, 5, 8, 15, tzinfo=SHANGHAI) + + result = adapter.compute_bazi(sample) + payload = result.to_dict() + + assert payload["pillars"]["year"]["text"] == result.pillars["year"].text + assert payload["metadata"]["solar"]["timezone"] == "Asia/Shanghai" + assert payload["metadata"]["solar_terms"]["year_boundary"]["name"] == "立春" + assert payload["metadata"]["source"]["library"] == "lunar_python" + assert payload["text"] == result.text