diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..2cf3075d1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*.pyi +*.pyo +*.pyd +.Python +.env +.venv +venv/ +.envrc +.idea/ +.vscode/ diff --git a/README.md b/README.md index a1f92f5862..ac993ef52b 100644 --- a/README.md +++ b/README.md @@ -1 +1,41 @@ -See you later \ No newline at end of file +# BaZi Solar Time Utilities + +This repository exposes a small Python module that converts civil birth times to +true solar times suitable for BaZi (Four Pillars) astrology. The public API is +available through `bazi.solar_time.corrected_birth_time` and returns a +`SolarTimeResult` dataclass with intermediate correction values. + +## Algorithm overview + +The implementation follows the reference equations used by the NOAA Solar +Calculator and makes the following assumptions: + +1. **Longitude correction** – The supplied civil time is expressed in the + timezone's standard meridian. A longitudinal offset of `4 minutes` per degree + east (positive) or west (negative) is applied to account for the observer's + longitude relative to the timezone centre. +2. **Equation of time** – The difference between the apparent and mean sun is + calculated using the fractional-day formula published by NOAA. The resulting + value is accurate to within approximately 30 seconds for contemporary dates + and sufficiently precise for astrological work. +3. **Daylight saving time** – Historical daylight saving transitions are + honoured through Python's `zoneinfo`. Input datetimes are first reduced to + local *standard* time before corrections are applied. The resulting solar + time is returned with the timezone's standard offset, and helper methods are + provided to convert it back to the observed timezone if needed. +4. **Default location** – When no longitude is supplied, the module assumes the + urban Shanghai coordinate of `121.4737°E` with the timezone + `Asia/Shanghai`, mirroring common BaZi practice. + +The correction pipeline is therefore: + +1. Normalize the input datetime to the requested timezone. +2. Remove any daylight saving offset to obtain local standard time. +3. Compute the equation-of-time term for the resulting fractional day. +4. Compute the longitude correction relative to the timezone's standard + meridian. +5. Apply both corrections to produce the true solar birth time. + +See `tests/test_solar_time.py` for concrete examples that illustrate the public +API and expected corrections for both contemporary Shanghai births and +historical daylight saving scenarios. diff --git a/bazi/__init__.py b/bazi/__init__.py new file mode 100644 index 0000000000..23ca79c371 --- /dev/null +++ b/bazi/__init__.py @@ -0,0 +1,15 @@ +"""Bazi utilities for astronomical calculations.""" + +from .solar_time import ( + SHANGHAI_LONGITUDE, + SHANGHAI_TIMEZONE, + SolarTimeResult, + corrected_birth_time, +) + +__all__ = [ + "SHANGHAI_LONGITUDE", + "SHANGHAI_TIMEZONE", + "SolarTimeResult", + "corrected_birth_time", +] diff --git a/bazi/solar_time.py b/bazi/solar_time.py new file mode 100644 index 0000000000..e6b448c445 --- /dev/null +++ b/bazi/solar_time.py @@ -0,0 +1,193 @@ +"""True solar time corrections for BaZi astrology. + +This module exposes utilities to translate observed civil birth times to +true solar times for use in BaZi (Four Pillars) calculations. The main entry +point is :func:`corrected_birth_time`, which applies both the longitude +correction for the observation site and the astronomical equation of time. + +The implementation follows the reference equations published by the U.S. NOAA +Solar Calculator and assumes a spherical Earth with a mean solar day. It is +accurate to within ~30 seconds for modern dates, which is sufficient for +astrological purposes. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from math import cos, sin, pi +from typing import Optional + +from zoneinfo import ZoneInfo + +SHANGHAI_TIMEZONE = "Asia/Shanghai" +SHANGHAI_LONGITUDE = 121.4737 # degrees East of Greenwich + + +@dataclass(frozen=True) +class SolarTimeResult: + """Container with intermediate values for a solar time correction.""" + + original_datetime: datetime + """The birth datetime normalised to the requested timezone.""" + + timezone: str + """IANA timezone name used for the correction.""" + + longitude: float + """Geodetic longitude in decimal degrees East of Greenwich.""" + + is_dst: bool + """Whether daylight saving time was active for the supplied moment.""" + + equation_of_time: timedelta + """Astronomical equation-of-time contribution (apparent vs mean sun).""" + + longitude_correction: timedelta + """Longitude correction relative to the timezone's standard meridian.""" + + total_correction: timedelta + """Sum of equation-of-time and longitude corrections.""" + + standard_datetime: datetime + """Local *standard* time (civil time with DST removed).""" + + true_solar_datetime: datetime + """True solar time expressed with the timezone's standard UTC offset.""" + + def as_timezone(self, timezone_name: Optional[str] = None) -> datetime: + """Return the true solar time represented in a target timezone. + + Parameters + ---------- + timezone_name: + Optional IANA timezone. If not provided the original timezone is + used. The conversion preserves the underlying instant represented + by :attr:`true_solar_datetime`. + """ + + tz = ZoneInfo(timezone_name or self.timezone) + return self.true_solar_datetime.astimezone(tz) + + @property + def corrected_birth_time(self) -> datetime: + """Alias for :attr:`true_solar_datetime` for ergonomic access.""" + + return self.true_solar_datetime + + +def corrected_birth_time( + birth_datetime: datetime, + longitude: Optional[float] = None, + timezone_name: str = SHANGHAI_TIMEZONE, +) -> SolarTimeResult: + """Return the true solar birth time for a civil birth record. + + Parameters + ---------- + birth_datetime: + Observed civil birth time. Either naive (interpreted in ``timezone``) + or timezone-aware. Historical daylight saving time transitions are + honoured via ``zoneinfo``. + longitude: + Geographic longitude in decimal degrees east of Greenwich. Defaults to + the urban Shanghai longitude recommended by the Chinese Astronomical + Society (121.4737°E). + timezone_name: + IANA timezone name where the birth was recorded. Defaults to + ``"Asia/Shanghai"``. + + Returns + ------- + SolarTimeResult + A dataclass exposing the corrected time and intermediate terms. + + Raises + ------ + ValueError + If the timezone cannot be resolved. + TypeError + If ``longitude`` cannot be interpreted as a float. + """ + + tz = _resolve_timezone(timezone_name) + + local_dt = _to_local_datetime(birth_datetime, tz) + + dst_offset = local_dt.dst() or timedelta(0) + utc_offset = local_dt.utcoffset() + if utc_offset is None: + raise ValueError("Unable to determine UTC offset for the supplied datetime.") + + standard_offset = utc_offset - dst_offset + + if longitude is None: + longitude = SHANGHAI_LONGITUDE + + try: + longitude_value = float(longitude) + except (TypeError, ValueError) as exc: # pragma: no cover - defensive guard + raise TypeError("Longitude must be a numeric value.") from exc + + standard_naive = (local_dt - dst_offset).replace(tzinfo=None) + + equation_minutes = _equation_of_time_minutes(standard_naive) + equation_delta = timedelta(minutes=equation_minutes) + + lstm = _standard_meridian_degrees(standard_offset) + longitude_minutes = 4.0 * (longitude_value - lstm) + longitude_delta = timedelta(minutes=longitude_minutes) + + total_delta = equation_delta + longitude_delta + + solar_naive = standard_naive + total_delta + + standard_tz = timezone(standard_offset, name=f"{timezone_name} standard") + + standard_aware = standard_naive.replace(tzinfo=standard_tz) + solar_aware = solar_naive.replace(tzinfo=standard_tz) + + return SolarTimeResult( + original_datetime=local_dt, + timezone=timezone_name, + longitude=longitude_value, + is_dst=bool(dst_offset), + equation_of_time=equation_delta, + longitude_correction=longitude_delta, + total_correction=total_delta, + standard_datetime=standard_aware, + true_solar_datetime=solar_aware, + ) + + +def _resolve_timezone(timezone_name: str) -> ZoneInfo: + try: + return ZoneInfo(timezone_name) + except Exception as exc: # pragma: no cover - defensive guard + raise ValueError(f"Unknown timezone '{timezone_name}'.") from exc + + +def _to_local_datetime(dt: datetime, tz: ZoneInfo) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=tz) + return dt.astimezone(tz) + + +def _standard_meridian_degrees(offset: timedelta) -> float: + return offset.total_seconds() / 3600.0 * 15.0 + + +def _equation_of_time_minutes(dt: datetime) -> float: + """Return the equation-of-time value in minutes for a local standard time.""" + + day_of_year = dt.timetuple().tm_yday + minutes_since_midnight = dt.hour * 60 + dt.minute + dt.second / 60.0 + gamma = (2.0 * pi / 365.0) * (day_of_year - 1 + (minutes_since_midnight - 720.0) / 1440.0) + + return 229.18 * ( + 0.000075 + + 0.001868 * cos(gamma) + - 0.032077 * sin(gamma) + - 0.014615 * cos(2 * gamma) + - 0.040849 * sin(2 * gamma) + ) diff --git a/tests/test_solar_time.py b/tests/test_solar_time.py new file mode 100644 index 0000000000..1881b98b52 --- /dev/null +++ b/tests/test_solar_time.py @@ -0,0 +1,66 @@ +from datetime import datetime, timedelta + +import pytest + +from bazi.solar_time import ( + SHANGHAI_LONGITUDE, + SHANGHAI_TIMEZONE, + SolarTimeResult, + corrected_birth_time, +) + + +def minutes(td: timedelta) -> float: + return td.total_seconds() / 60.0 + + +def test_corrected_birth_time_defaults_shanghai(): + birth = datetime(2020, 10, 1, 8, 30) # naive local civil time + + result = corrected_birth_time(birth) + + assert isinstance(result, SolarTimeResult) + assert result.timezone == SHANGHAI_TIMEZONE + assert result.longitude == pytest.approx(SHANGHAI_LONGITUDE, rel=1e-9) + assert result.is_dst is False + + # Values obtained from NOAA solar equations for the given moment. + assert minutes(result.equation_of_time) == pytest.approx(10.75236935, rel=1e-6) + assert minutes(result.longitude_correction) == pytest.approx(5.8948, rel=1e-9) + assert minutes(result.total_correction) == pytest.approx(16.64716935, rel=1e-6) + + assert result.standard_datetime.utcoffset() == timedelta(hours=8) + expected_solar = datetime(2020, 10, 1, 8, 46, 38, 830161, tzinfo=result.corrected_birth_time.tzinfo) + assert abs((result.corrected_birth_time - expected_solar).total_seconds()) < 1e-6 + + +def test_dst_adjustment_uses_standard_time_reference(): + birth = datetime(1988, 7, 1, 2, 30) + + result = corrected_birth_time(birth) + + assert result.is_dst is True + # Local 02:30 DST converts to 01:30 standard time. + assert result.standard_datetime.time() == datetime(1988, 7, 1, 1, 30).time() + assert result.standard_datetime.utcoffset() == timedelta(hours=8) + + # Solar time applies corrections atop the standard time baseline. + assert pytest.approx(minutes(result.total_correction), rel=1e-6) == 2.3196947833333335 + assert result.corrected_birth_time.time() == datetime(1988, 7, 1, 1, 32, 19, 181687).time() + + # Converting back to the observable timezone re-applies DST. + solar_in_tz = result.as_timezone(SHANGHAI_TIMEZONE) + assert solar_in_tz.utcoffset() == timedelta(hours=9) + assert solar_in_tz.time() == datetime(1988, 7, 1, 2, 32, 19, 181687).time() + + +def test_longitude_at_standard_meridian_has_zero_offset(): + birth = datetime(2023, 3, 21, 12, 0) + + result = corrected_birth_time(birth, longitude=120.0) # 8h * 15° + + assert minutes(result.longitude_correction) == pytest.approx(0.0, abs=1e-9) + assert minutes(result.total_correction) == pytest.approx( + minutes(result.equation_of_time), rel=1e-6 + ) + assert result.corrected_birth_time.time() == datetime(2023, 3, 21, 11, 52, 8, 514413).time()