Skip to content

Astronomy

Shared, tradition-neutral astronomy used by the computed traditions: Julian-day and delta-T conversion, nutation, solar terms, the time-model helpers, and the injectable Ephemeris protocol with its pure-Python BuiltinEphemeris (and FixedEphemeris test stub).

astronomy

Shared astronomy primitives used by multiple traditions.

The package contains deterministic Julian-day helpers, solar-term utilities, and an injectable ephemeris protocol. The default backend is pure Python and has no required runtime dependencies.

Example
from datetime import UTC, datetime

from fortune_telling_core.astronomy import BuiltinEphemeris, julian_day_utc

ephemeris = BuiltinEphemeris()
jd_utc = julian_day_utc(datetime(2026, 6, 12, tzinfo=UTC))
assert ephemeris.supported_bodies()

JIE_LONGITUDES module-attribute

JIE_LONGITUDES: tuple[float, ...] = (
    315.0,
    345.0,
    15.0,
    45.0,
    75.0,
    105.0,
    135.0,
    165.0,
    195.0,
    225.0,
    255.0,
    285.0,
)

Body

Bases: StrEnum

Astronomical body identifiers accepted by shared ephemeris backends.

Values are stable lowercase strings so they can be stored in reading modifiers and replay fixtures.

Example
from fortune_telling_core.astronomy import Body

assert Body.SUN.value == "sun"

SUN class-attribute instance-attribute

SUN = 'sun'

MOON class-attribute instance-attribute

MOON = 'moon'

MERCURY class-attribute instance-attribute

MERCURY = 'mercury'

VENUS class-attribute instance-attribute

VENUS = 'venus'

MARS class-attribute instance-attribute

MARS = 'mars'

JUPITER class-attribute instance-attribute

JUPITER = 'jupiter'

SATURN class-attribute instance-attribute

SATURN = 'saturn'

URANUS class-attribute instance-attribute

URANUS = 'uranus'

NEPTUNE class-attribute instance-attribute

NEPTUNE = 'neptune'

PLUTO class-attribute instance-attribute

PLUTO = 'pluto'

NORTH_NODE class-attribute instance-attribute

NORTH_NODE = 'north_node'

SOUTH_NODE class-attribute instance-attribute

SOUTH_NODE = 'south_node'

BuiltinEphemeris

Pure-Python deterministic ephemeris shipped with the package.

The built-in backend keeps runtime dependencies empty and is suitable for deterministic tests and general symbolic work. It is not a replacement for a high-precision professional astronomy backend.

Example
from fortune_telling_core.astronomy import Body, BuiltinEphemeris

ephemeris = BuiltinEphemeris()
sun = ephemeris.position(Body.SUN, 2451545.0)

id class-attribute instance-attribute

id = 'astro.ephemeris.builtin'

version class-attribute instance-attribute

version = '0.2.1'

supported_bodies

supported_bodies() -> frozenset[Body]

Return all bodies accepted by the built-in ephemeris.

Source code in src/fortune_telling_core/astronomy/ephemeris/builtin.py
def supported_bodies(self) -> frozenset[Body]:
    """Return all bodies accepted by the built-in ephemeris."""

    return frozenset(Body)

position

position(body: Body, jd_tt: float) -> EclipticPosition

Return an apparent geocentric ecliptic position.

Parameters:

Name Type Description Default
body Body

Body to compute.

required
jd_tt float

Julian day on the Terrestrial Time scale.

required

Returns:

Type Description
EclipticPosition

Ecliptic longitude, approximate longitude speed, and Moon latitude.

Raises:

Type Description
EphemerisError

If body is not supported.

Source code in src/fortune_telling_core/astronomy/ephemeris/builtin.py
def position(self, body: Body, jd_tt: float) -> EclipticPosition:
    """Return an apparent geocentric ecliptic position.

    Args:
        body: Body to compute.
        jd_tt: Julian day on the Terrestrial Time scale.

    Returns:
        Ecliptic longitude, approximate longitude speed, and Moon latitude.

    Raises:
        EphemerisError: If ``body`` is not supported.
    """

    if body == Body.SOUTH_NODE:
        north = self.position(Body.NORTH_NODE, jd_tt)
        return EclipticPosition(north.longitude + 180.0, north.speed)
    longitude = _longitude(body, jd_tt)
    previous_lon = _longitude(body, jd_tt - 0.5)
    next_lon = _longitude(body, jd_tt + 0.5)
    speed = _signed_delta(next_lon, previous_lon)
    latitude = _latitude(body, jd_tt)
    return EclipticPosition(longitude, speed, latitude)

FixedEphemeris

FixedEphemeris(positions: Mapping[Body, EclipticPosition])

Deterministic ephemeris backed by a fixed position mapping.

This backend is intended for tests, examples, and replay fixtures where the caller wants exact symbolic placements without astronomical calculation.

Parameters:

Name Type Description Default
positions Mapping[Body, EclipticPosition]

Mapping from body identifiers to ecliptic positions. If a north node is supplied without a south node, the south node is derived as the opposite longitude.

required
Example
from fortune_telling_core.astronomy import Body, EclipticPosition, FixedEphemeris

ephemeris = FixedEphemeris({Body.SUN: EclipticPosition(10.0)})
assert ephemeris.position(Body.SUN, 0.0).longitude == 10.0
Source code in src/fortune_telling_core/astronomy/ephemeris/fixed.py
def __init__(self, positions: Mapping[Body, EclipticPosition]) -> None:
    self._positions = dict(positions)
    if Body.NORTH_NODE in self._positions and Body.SOUTH_NODE not in self._positions:
        north = self._positions[Body.NORTH_NODE]
        self._positions[Body.SOUTH_NODE] = EclipticPosition(
            north.longitude + 180.0, north.speed
        )

id class-attribute instance-attribute

id = 'astro.ephemeris.fixed'

version class-attribute instance-attribute

version = '0.1.0'

supported_bodies

supported_bodies() -> frozenset[Body]

Return the bodies present in the fixed mapping.

Source code in src/fortune_telling_core/astronomy/ephemeris/fixed.py
def supported_bodies(self) -> frozenset[Body]:
    """Return the bodies present in the fixed mapping."""

    return frozenset(self._positions)

position

position(body: Body, jd_tt: float) -> EclipticPosition

Return the fixed position for a body.

Parameters:

Name Type Description Default
body Body

Body to look up.

required
jd_tt float

Ignored Julian day, accepted for protocol compatibility.

required

Returns:

Type Description
EclipticPosition

The configured ecliptic position.

Raises:

Type Description
EphemerisError

If no position exists for body.

Source code in src/fortune_telling_core/astronomy/ephemeris/fixed.py
def position(self, body: Body, jd_tt: float) -> EclipticPosition:
    """Return the fixed position for a body.

    Args:
        body: Body to look up.
        jd_tt: Ignored Julian day, accepted for protocol compatibility.

    Returns:
        The configured ecliptic position.

    Raises:
        EphemerisError: If no position exists for ``body``.
    """

    del jd_tt
    value = self._positions.get(body)
    if value is None:
        raise EphemerisError(f"fixed ephemeris has no position for {body}")
    return value

Ephemeris

Bases: Protocol

Protocol for injectable astronomy backends.

Implementations provide apparent geocentric ecliptic positions in degrees. Latitude may be None when a backend or body does not compute it. The fortune-telling traditions depend only on this protocol, so applications can bring their own precision and licensing choices.

Attributes:

Name Type Description
id str

Stable backend identifier recorded in reading provenance.

version str

Backend version recorded in reading provenance.

id instance-attribute

id: str

version instance-attribute

version: str

position

position(body: Body, jd_tt: float) -> EclipticPosition

Return an apparent geocentric ecliptic position.

Parameters:

Name Type Description Default
body Body

Body to compute.

required
jd_tt float

Julian day on the Terrestrial Time scale.

required

Returns:

Type Description
EclipticPosition

Ecliptic position for the requested body.

Raises:

Type Description
EphemerisError

If the backend cannot compute the requested body.

Source code in src/fortune_telling_core/astronomy/ephemeris/protocol.py
def position(self, body: Body, jd_tt: float) -> EclipticPosition:
    """Return an apparent geocentric ecliptic position.

    Args:
        body: Body to compute.
        jd_tt: Julian day on the Terrestrial Time scale.

    Returns:
        Ecliptic position for the requested body.

    Raises:
        EphemerisError: If the backend cannot compute the requested body.
    """

supported_bodies

supported_bodies() -> frozenset[Body]

Return the bodies supported by this ephemeris.

Source code in src/fortune_telling_core/astronomy/ephemeris/protocol.py
def supported_bodies(self) -> frozenset[Body]:
    """Return the bodies supported by this ephemeris."""

AstronomyError

Bases: FortuneTellingError

Base error for shared astronomy helpers.

EphemerisError

Bases: AstronomyError

Raised when an ephemeris cannot provide a requested position.

LunisolarDate dataclass

LunisolarDate(
    year: int, month: int, day: int, is_leap_month: bool
)

A lunisolar (旧暦) date.

year instance-attribute

year: int

month instance-attribute

month: int

day instance-attribute

day: int

is_leap_month instance-attribute

is_leap_month: bool

EclipticPosition dataclass

EclipticPosition(
    longitude: float,
    speed: float = 0.0,
    latitude: float | None = None,
)

Geocentric ecliptic position returned by an ephemeris.

Parameters:

Name Type Description Default
longitude float

Ecliptic longitude in degrees. The stored value is normalized to [0, 360).

required
speed float

Longitude speed in degrees per day. Negative speed is treated as retrograde motion.

0.0
latitude float | None

Ecliptic latitude in degrees, when the ephemeris computes it. None means latitude is unavailable for that body/backend.

None
Example
from fortune_telling_core.astronomy import EclipticPosition

mars = EclipticPosition(721.0, -0.2)
assert mars.longitude == 1.0
assert mars.retrograde

longitude instance-attribute

longitude: float

speed class-attribute instance-attribute

speed: float = 0.0

latitude class-attribute instance-attribute

latitude: float | None = None

retrograde property

retrograde: bool

Whether the longitude speed is negative.

TimeModel

Bases: StrEnum

Civil-time adjustment models used by date-based traditions.

CLOCK keeps the supplied aware datetime unchanged. LOCAL_MEAN_TIME shifts by longitude relative to the timezone offset. TRUE_SOLAR also applies the equation of time.

CLOCK class-attribute instance-attribute

CLOCK = 'clock'

LOCAL_MEAN_TIME class-attribute instance-attribute

LOCAL_MEAN_TIME = 'lmt'

TRUE_SOLAR class-attribute instance-attribute

TRUE_SOLAR = 'true_solar'

delta_t_seconds

delta_t_seconds(year: float) -> float

Approximate Delta-T for a decimal year.

Parameters:

Name Type Description Default
year float

Decimal Gregorian year.

required

Returns:

Type Description
float

Approximate TT - UT in seconds.

Source code in src/fortune_telling_core/astronomy/deltat.py
def delta_t_seconds(year: float) -> float:
    """Approximate Delta-T for a decimal year.

    Args:
        year: Decimal Gregorian year.

    Returns:
        Approximate ``TT - UT`` in seconds.
    """

    if year < 948.0:
        u = (year - 2000.0) / 100.0
        return 2177.0 + 497.0 * u + 44.1 * u * u
    if year < 1600.0:
        u = (year - 2000.0) / 100.0
        return 102.0 + 102.0 * u + 25.3 * u * u
    if year < 2000.0:
        t = year - 2000.0
        return 63.86 + 0.3345 * t - 0.060374 * t * t + 0.0017275 * t**3 + 0.000651814 * t**4
    if year < 2050.0:
        t = year - 2000.0
        return 62.92 + 0.32217 * t + 0.005589 * t * t
    u = (year - 1820.0) / 100.0
    return -20.0 + 32.0 * u * u

jd_tt_from_utc

jd_tt_from_utc(jd_utc: float) -> float

Convert a UTC Julian day to Terrestrial Time.

Parameters:

Name Type Description Default
jd_utc float

Julian day on the UTC/UT scale.

required

Returns:

Type Description
float

Julian day adjusted by the built-in Delta-T approximation.

Source code in src/fortune_telling_core/astronomy/deltat.py
def jd_tt_from_utc(jd_utc: float) -> float:
    """Convert a UTC Julian day to Terrestrial Time.

    Args:
        jd_utc: Julian day on the UTC/UT scale.

    Returns:
        Julian day adjusted by the built-in Delta-T approximation.
    """

    year = 2000.0 + (jd_utc - 2451545.0) / 365.2425
    return jd_utc + delta_t_seconds(year) / 86400.0

julian_centuries

julian_centuries(jd: float) -> float

Convert a Julian day to Julian centuries since J2000.0.

Parameters:

Name Type Description Default
jd float

Julian day in any compatible time scale.

required

Returns:

Type Description
float

Centuries elapsed since Julian day 2451545.0.

Source code in src/fortune_telling_core/astronomy/julian.py
def julian_centuries(jd: float) -> float:
    """Convert a Julian day to Julian centuries since J2000.0.

    Args:
        jd: Julian day in any compatible time scale.

    Returns:
        Centuries elapsed since Julian day 2451545.0.
    """

    return (jd - 2451545.0) / 36525.0

julian_day_from_date

julian_day_from_date(
    year: int, month: int, day: int
) -> int

Return the midnight Julian day number for a Gregorian date.

Parameters:

Name Type Description Default
year int

Gregorian calendar year.

required
month int

Gregorian calendar month, from 1 to 12.

required
day int

Gregorian day of month.

required

Returns:

Type Description
int

The integer Julian day number for midnight UTC on the date.

Source code in src/fortune_telling_core/astronomy/julian.py
def julian_day_from_date(year: int, month: int, day: int) -> int:
    """Return the midnight Julian day number for a Gregorian date.

    Args:
        year: Gregorian calendar year.
        month: Gregorian calendar month, from 1 to 12.
        day: Gregorian day of month.

    Returns:
        The integer Julian day number for midnight UTC on the date.
    """

    if month <= 2:
        year -= 1
        month += 12
    correction_a = year // 100
    correction_b = 2 - correction_a + correction_a // 4
    return int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + day + correction_b - 1524

julian_day_utc

julian_day_utc(value: datetime) -> float

Convert an aware civil datetime to a UTC Julian day.

Parameters:

Name Type Description Default
value datetime

A timezone-aware datetime. Naive datetimes are rejected by the shared time coercion helper.

required

Returns:

Type Description
float

The Julian day as a float, including the fractional day.

Raises:

Type Description
ValidationError

If value is naive.

Source code in src/fortune_telling_core/astronomy/julian.py
def julian_day_utc(value: datetime) -> float:
    """Convert an aware civil datetime to a UTC Julian day.

    Args:
        value: A timezone-aware datetime. Naive datetimes are rejected by the
            shared time coercion helper.

    Returns:
        The Julian day as a float, including the fractional day.

    Raises:
        ValidationError: If ``value`` is naive.
    """

    aware = ensure_aware(value, "birth_datetime").astimezone(UTC)
    year = aware.year
    month = aware.month
    day = (
        aware.day
        + (
            aware.hour
            + (aware.minute + (aware.second + aware.microsecond / 1_000_000.0) / 60.0) / 60.0
        )
        / 24.0
    )

    if month <= 2:
        year -= 1
        month += 12
    correction_a = year // 100
    correction_b = 2 - correction_a + correction_a // 4
    return int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + day + correction_b - 1524.5

civil_day_number

civil_day_number(jd_tt: float, tz_hours: float) -> int

Return the integer civil day number of a TT Julian day in a timezone.

The result is comparable with :func:fortune_telling_core.astronomy.julian.julian_day_from_date for the same local date.

Source code in src/fortune_telling_core/astronomy/lunisolar.py
def civil_day_number(jd_tt: float, tz_hours: float) -> int:
    """Return the integer civil day number of a TT Julian day in a timezone.

    The result is comparable with
    :func:`fortune_telling_core.astronomy.julian.julian_day_from_date` for the
    same local date.
    """

    delta_t_days = delta_t_seconds(_year_of_jd(jd_tt)) / 86400.0
    jd_ut = jd_tt - delta_t_days
    return math.floor(jd_ut + tz_hours / 24.0 + 0.5)

new_moon_on_or_before

new_moon_on_or_before(
    jd_tt: float, ephemeris: Ephemeris
) -> float

Return the Julian day (TT) of the last new moon at or before jd_tt.

Source code in src/fortune_telling_core/astronomy/lunisolar.py
def new_moon_on_or_before(jd_tt: float, ephemeris: Ephemeris) -> float:
    """Return the Julian day (TT) of the last new moon at or before ``jd_tt``."""

    cycle = round((jd_tt - _NEW_MOON_EPOCH_JD) / _SYNODIC_MONTH)
    jd = _refine_new_moon(_NEW_MOON_EPOCH_JD + cycle * _SYNODIC_MONTH, ephemeris)
    while jd > jd_tt + 0.5:
        cycle -= 1
        jd = _refine_new_moon(_NEW_MOON_EPOCH_JD + cycle * _SYNODIC_MONTH, ephemeris)
    while True:
        nxt = _refine_new_moon(_NEW_MOON_EPOCH_JD + (cycle + 1) * _SYNODIC_MONTH, ephemeris)
        if nxt > jd_tt + 0.5:
            break
        cycle += 1
        jd = nxt
    return jd

to_lunisolar

to_lunisolar(
    jd_tt: float, *, tz_hours: float, ephemeris: Ephemeris
) -> LunisolarDate

Convert a TT Julian day to a lunisolar (旧暦) date.

Parameters:

Name Type Description Default
jd_tt float

Instant to convert, as a Terrestrial-Time Julian day.

required
tz_hours float

Civil timezone offset (hours east of UTC) in which lunar day boundaries are taken. Japanese 旧暦 uses 9.0.

required
ephemeris Ephemeris

Ephemeris backend for Sun and Moon positions.

required

Returns:

Type Description
LunisolarDate

The lunisolar date.

Source code in src/fortune_telling_core/astronomy/lunisolar.py
def to_lunisolar(jd_tt: float, *, tz_hours: float, ephemeris: Ephemeris) -> LunisolarDate:
    """Convert a TT Julian day to a lunisolar (旧暦) date.

    Args:
        jd_tt: Instant to convert, as a Terrestrial-Time Julian day.
        tz_hours: Civil timezone offset (hours east of UTC) in which lunar
            day boundaries are taken. Japanese 旧暦 uses ``9.0``.
        ephemeris: Ephemeris backend for Sun and Moon positions.

    Returns:
        The lunisolar date.
    """

    target_day = civil_day_number(jd_tt, tz_hours)
    month_start = new_moon_on_or_before(jd_tt, ephemeris)
    gregorian_year = _year_of_jd(jd_tt)

    # Two candidate month-11 anchors (new moon starting the solstice month).
    anchor_prev = new_moon_on_or_before(_winter_solstice(gregorian_year - 1, ephemeris), ephemeris)
    anchor_this = new_moon_on_or_before(_winter_solstice(gregorian_year, ephemeris), ephemeris)

    if civil_day_number(month_start, tz_hours) >= civil_day_number(anchor_this, tz_hours):
        anchor = anchor_this
        anchor_year = gregorian_year
        next_anchor = new_moon_on_or_before(
            _winter_solstice(gregorian_year + 1, ephemeris), ephemeris
        )
    else:
        anchor = anchor_prev
        anchor_year = gregorian_year - 1
        next_anchor = anchor_this

    # Each month in this solstice block: its new-moon instant and civil day.
    months: list[int] = []
    month_jds: list[float] = []
    next_day = civil_day_number(next_anchor, tz_hours)
    nm = anchor
    while civil_day_number(nm, tz_hours) < next_day:
        month_jds.append(nm)
        months.append(civil_day_number(nm, tz_hours))
        nm = new_moon_on_or_before(nm + _SYNODIC_MONTH + 2.0, ephemeris)

    leap_index: int | None = None
    if len(months) == 13:
        for index in range(len(month_jds)):
            start = month_jds[index]
            end = month_jds[index + 1] if index + 1 < len(month_jds) else next_anchor
            if not _has_principal_term(start, end, tz_hours, ephemeris):
                leap_index = index
                break

    number = 11
    prev_number = 11
    numbers: list[int] = []
    leaps: list[bool] = []
    for index in range(len(months)):
        if index == leap_index:
            numbers.append(prev_number)
            leaps.append(True)
        else:
            numbers.append(number)
            leaps.append(False)
            prev_number = number
            number = number + 1 if number < 12 else 1

    target_index = 0
    for index in range(len(months)):
        if months[index] <= target_day:
            target_index = index
        else:
            break

    month_number = numbers[target_index]
    is_leap = leaps[target_index]
    day = target_day - months[target_index] + 1
    lunar_year = anchor_year if month_number >= 11 else anchor_year + 1
    return LunisolarDate(lunar_year, month_number, day, is_leap)

normalize_degrees

normalize_degrees(value: float) -> float

Normalize an angle to the half-open range [0, 360).

Parameters:

Name Type Description Default
value float

Angle in degrees.

required

Returns:

Type Description
float

The equivalent angle in degrees, from 0 inclusive to 360 exclusive.

Source code in src/fortune_telling_core/astronomy/position.py
def normalize_degrees(value: float) -> float:
    """Normalize an angle to the half-open range ``[0, 360)``.

    Args:
        value: Angle in degrees.

    Returns:
        The equivalent angle in degrees, from 0 inclusive to 360 exclusive.
    """

    result = value % 360.0
    if result < 0.0:
        result += 360.0
    return result

equation_of_time

equation_of_time(
    jd_tt: float, ephemeris: Ephemeris
) -> float

Approximate the equation of time for a Julian day.

Parameters:

Name Type Description Default
jd_tt float

Julian day on the Terrestrial Time scale.

required
ephemeris Ephemeris

Ephemeris backend used for the Sun's longitude.

required

Returns:

Type Description
float

Apparent solar time minus mean solar time, in minutes.

Raises:

Type Description
EphemerisError

If the backend cannot compute the Sun.

Source code in src/fortune_telling_core/astronomy/solar.py
def equation_of_time(jd_tt: float, ephemeris: Ephemeris) -> float:
    """Approximate the equation of time for a Julian day.

    Args:
        jd_tt: Julian day on the Terrestrial Time scale.
        ephemeris: Ephemeris backend used for the Sun's longitude.

    Returns:
        Apparent solar time minus mean solar time, in minutes.

    Raises:
        EphemerisError: If the backend cannot compute the Sun.
    """

    sun = math.radians(sun_longitude(jd_tt, ephemeris))
    eps = math.radians(true_obliquity(jd_tt))
    right_ascension = math.degrees(math.atan2(math.sin(sun) * math.cos(eps), math.cos(sun)))
    return wrap180(sun_longitude(jd_tt, ephemeris) - right_ascension) * 4.0

solar_longitude_crossing

solar_longitude_crossing(
    target_deg: float,
    jd_start: float,
    jd_end: float,
    ephemeris: Ephemeris,
    *,
    tolerance_deg: float = 1e-06,
    max_iterations: int = 100,
) -> float

Find when the Sun crosses a target longitude inside a bracket.

Parameters:

Name Type Description Default
target_deg float

Target apparent solar longitude in degrees.

required
jd_start float

Start of the Julian-day search bracket.

required
jd_end float

End of the Julian-day search bracket.

required
ephemeris Ephemeris

Ephemeris backend used for solar positions.

required
tolerance_deg float

Maximum remaining longitude error before returning.

1e-06
max_iterations int

Maximum bisection iterations.

100

Returns:

Type Description
float

Julian day of the crossing.

Raises:

Type Description
ValueError

If the target is not bracketed by the supplied interval.

EphemerisError

If the backend cannot compute the Sun.

Source code in src/fortune_telling_core/astronomy/solar.py
def solar_longitude_crossing(
    target_deg: float,
    jd_start: float,
    jd_end: float,
    ephemeris: Ephemeris,
    *,
    tolerance_deg: float = 1e-6,
    max_iterations: int = 100,
) -> float:
    """Find when the Sun crosses a target longitude inside a bracket.

    Args:
        target_deg: Target apparent solar longitude in degrees.
        jd_start: Start of the Julian-day search bracket.
        jd_end: End of the Julian-day search bracket.
        ephemeris: Ephemeris backend used for solar positions.
        tolerance_deg: Maximum remaining longitude error before returning.
        max_iterations: Maximum bisection iterations.

    Returns:
        Julian day of the crossing.

    Raises:
        ValueError: If the target is not bracketed by the supplied interval.
        EphemerisError: If the backend cannot compute the Sun.
    """

    target = normalize_degrees(target_deg)
    low = jd_start
    high = jd_end
    low_value = wrap180(sun_longitude(low, ephemeris) - target)
    high_value = wrap180(sun_longitude(high, ephemeris) - target)
    if low_value == 0.0:
        return low
    if high_value == 0.0:
        return high
    if low_value * high_value > 0.0:
        raise ValueError("solar longitude target is not bracketed")
    for _ in range(max_iterations):
        mid = (low + high) / 2.0
        mid_value = wrap180(sun_longitude(mid, ephemeris) - target)
        if abs(mid_value) <= tolerance_deg:
            return mid
        if low_value * mid_value <= 0.0:
            high = mid
            high_value = mid_value
        else:
            low = mid
            low_value = mid_value
    return (low + high) / 2.0

sun_longitude

sun_longitude(jd_tt: float, ephemeris: Ephemeris) -> float

Return the Sun's apparent ecliptic longitude.

Parameters:

Name Type Description Default
jd_tt float

Julian day on the Terrestrial Time scale.

required
ephemeris Ephemeris

Ephemeris backend used to compute the Sun's position.

required

Returns:

Type Description
float

Apparent geocentric longitude in degrees.

Raises:

Type Description
EphemerisError

If the backend does not support the Sun.

Source code in src/fortune_telling_core/astronomy/solar.py
def sun_longitude(jd_tt: float, ephemeris: Ephemeris) -> float:
    """Return the Sun's apparent ecliptic longitude.

    Args:
        jd_tt: Julian day on the Terrestrial Time scale.
        ephemeris: Ephemeris backend used to compute the Sun's position.

    Returns:
        Apparent geocentric longitude in degrees.

    Raises:
        EphemerisError: If the backend does not support the Sun.
    """

    return ephemeris.position(Body.SUN, jd_tt).longitude

adjacent_jie_crossing

adjacent_jie_crossing(
    jd_tt: float, ephemeris: Ephemeris, *, forward: bool
) -> float

Find the previous or next sectional solar-term crossing.

Parameters:

Name Type Description Default
jd_tt float

Julian day on the Terrestrial Time scale.

required
ephemeris Ephemeris

Ephemeris backend used for solar positions.

required
forward bool

If true, find the next jie. Otherwise find the previous jie.

required

Returns:

Type Description
float

Julian day of the adjacent sectional solar-term crossing.

Raises:

Type Description
ValueError

If no crossing is found in the fixed 40-day bracket.

EphemerisError

If the backend cannot compute the Sun.

Source code in src/fortune_telling_core/astronomy/solar_terms.py
def adjacent_jie_crossing(jd_tt: float, ephemeris: Ephemeris, *, forward: bool) -> float:
    """Find the previous or next sectional solar-term crossing.

    Args:
        jd_tt: Julian day on the Terrestrial Time scale.
        ephemeris: Ephemeris backend used for solar positions.
        forward: If true, find the next jie. Otherwise find the previous jie.

    Returns:
        Julian day of the adjacent sectional solar-term crossing.

    Raises:
        ValueError: If no crossing is found in the fixed 40-day bracket.
        EphemerisError: If the backend cannot compute the Sun.
    """

    current = sun_longitude(jd_tt, ephemeris)
    current_month = solar_month_index(current)
    target_index = (current_month + (1 if forward else 0)) % 12
    target = JIE_LONGITUDES[target_index]
    if forward:
        return solar_term_crossing(target, jd_tt, jd_tt + 40.0, ephemeris)
    return solar_term_crossing(target, jd_tt - 40.0, jd_tt, ephemeris)

lichun_crossing

lichun_crossing(year: int, ephemeris: Ephemeris) -> float

Find the Lichun crossing for a Gregorian year.

Parameters:

Name Type Description Default
year int

Gregorian calendar year.

required
ephemeris Ephemeris

Ephemeris backend used for solar positions.

required

Returns:

Type Description
float

Julian day on the Terrestrial Time scale when the Sun crosses

float

315 degrees.

Raises:

Type Description
ValueError

If the crossing is not found in the built-in bracket.

EphemerisError

If the backend cannot compute the Sun.

Source code in src/fortune_telling_core/astronomy/solar_terms.py
def lichun_crossing(year: int, ephemeris: Ephemeris) -> float:
    """Find the Lichun crossing for a Gregorian year.

    Args:
        year: Gregorian calendar year.
        ephemeris: Ephemeris backend used for solar positions.

    Returns:
        Julian day on the Terrestrial Time scale when the Sun crosses
        315 degrees.

    Raises:
        ValueError: If the crossing is not found in the built-in bracket.
        EphemerisError: If the backend cannot compute the Sun.
    """

    start = jd_tt_from_utc(julian_day_utc(datetime(year, 1, 20, tzinfo=UTC)))
    end = jd_tt_from_utc(julian_day_utc(datetime(year, 2, 20, tzinfo=UTC)))
    return solar_term_crossing(315.0, start, end, ephemeris)

solar_month_index

solar_month_index(solar_longitude: float) -> int

Return the sectional solar month index for a longitude.

Parameters:

Name Type Description Default
solar_longitude float

Apparent solar longitude in degrees.

required

Returns:

Type Description
int

Zero-based index where 0 begins at Lichun, 315 degrees.

Source code in src/fortune_telling_core/astronomy/solar_terms.py
def solar_month_index(solar_longitude: float) -> int:
    """Return the sectional solar month index for a longitude.

    Args:
        solar_longitude: Apparent solar longitude in degrees.

    Returns:
        Zero-based index where 0 begins at Lichun, 315 degrees.
    """

    return int(((solar_longitude - 315.0) % 360.0) // 30.0)

solar_term_crossing

solar_term_crossing(
    target: float,
    jd_start: float,
    jd_end: float,
    ephemeris: Ephemeris,
) -> float

Find a solar-term crossing by scanning one-day brackets.

Parameters:

Name Type Description Default
target float

Target apparent solar longitude in degrees.

required
jd_start float

Start Julian day of the search interval.

required
jd_end float

End Julian day of the search interval.

required
ephemeris Ephemeris

Ephemeris backend used for solar positions.

required

Returns:

Type Description
float

Julian day of the first crossing found in the interval.

Raises:

Type Description
ValueError

If the crossing is not found.

EphemerisError

If the backend cannot compute the Sun.

Source code in src/fortune_telling_core/astronomy/solar_terms.py
def solar_term_crossing(
    target: float, jd_start: float, jd_end: float, ephemeris: Ephemeris
) -> float:
    """Find a solar-term crossing by scanning one-day brackets.

    Args:
        target: Target apparent solar longitude in degrees.
        jd_start: Start Julian day of the search interval.
        jd_end: End Julian day of the search interval.
        ephemeris: Ephemeris backend used for solar positions.

    Returns:
        Julian day of the first crossing found in the interval.

    Raises:
        ValueError: If the crossing is not found.
        EphemerisError: If the backend cannot compute the Sun.
    """

    step = 1.0
    low = jd_start
    while low < jd_end:
        high = min(low + step, jd_end)
        try:
            return solar_longitude_crossing(target, low, high, ephemeris)
        except ValueError:
            low = high
    raise ValueError("solar term crossing not found")

effective_datetime

effective_datetime(
    value: datetime,
    longitude: float,
    time_model: TimeModel,
    ephemeris: Ephemeris,
) -> datetime

Apply a tradition time model to an aware datetime.

Parameters:

Name Type Description Default
value datetime

Timezone-aware source datetime.

required
longitude float

Geographic longitude in degrees east.

required
time_model TimeModel

Time adjustment model to apply.

required
ephemeris Ephemeris

Ephemeris backend used by true solar time.

required

Returns:

Type Description
datetime

The adjusted datetime. The original timezone information is preserved.

Raises:

Type Description
EphemerisError

If true solar time is requested and the backend cannot compute the Sun.

Source code in src/fortune_telling_core/astronomy/time_model.py
def effective_datetime(
    value: datetime, longitude: float, time_model: TimeModel, ephemeris: Ephemeris
) -> datetime:
    """Apply a tradition time model to an aware datetime.

    Args:
        value: Timezone-aware source datetime.
        longitude: Geographic longitude in degrees east.
        time_model: Time adjustment model to apply.
        ephemeris: Ephemeris backend used by true solar time.

    Returns:
        The adjusted datetime. The original timezone information is preserved.

    Raises:
        EphemerisError: If true solar time is requested and the backend cannot
            compute the Sun.
    """

    if time_model == TimeModel.CLOCK:
        return value
    offset = value.utcoffset()
    offset_hours = 0.0 if offset is None else offset.total_seconds() / 3600.0
    lmt_shift = longitude / 15.0 - offset_hours
    result = value + timedelta(hours=lmt_shift)
    if time_model == TimeModel.TRUE_SOLAR:
        jd_tt = jd_tt_from_utc(julian_day_utc(value))
        result += timedelta(minutes=equation_of_time(jd_tt, ephemeris))
    return result