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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
.pytest_cache/
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
See you later
# 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
```
24 changes: 24 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 19 additions & 0 deletions src/calendar_adapter/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
261 changes: 261 additions & 0 deletions src/calendar_adapter/adapter.py
Original file line number Diff line number Diff line change
@@ -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
Loading