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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
*.pyi
*.pyo
*.pyd
.Python
.env
.venv
venv/
.envrc
.idea/
.vscode/
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
See you later
# 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.
15 changes: 15 additions & 0 deletions bazi/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
193 changes: 193 additions & 0 deletions bazi/solar_time.py
Original file line number Diff line number Diff line change
@@ -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)
)
66 changes: 66 additions & 0 deletions tests/test_solar_time.py
Original file line number Diff line number Diff line change
@@ -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()