Source code for stellium.engines.directions

"""Primary directions calculation engine.

This module provides primary directions calculations for Stellium, supporting
both zodiacal (2D/Regiomontanus-style) and mundane (3D/Placidus) methods.

Primary directions track when a "promissor" (moving point) reaches a
"significator" (target point) via the Earth's daily rotation. The resulting
arc is converted to years using a time key.

Example:
    >>> from stellium.engines.directions import DirectionsEngine
    >>> engine = DirectionsEngine(chart)
    >>> result = engine.direct("Sun", "ASC")
    >>> print(f"Sun to ASC: age {result.age:.1f}")
"""

from __future__ import annotations

import datetime as dt
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol

import swisseph as swe

from stellium.engines.dignities import DIGNITIES

if TYPE_CHECKING:
    from stellium.core.models import CalculatedChart, CelestialPosition


# =============================================================================
# SECTION 1: DATA MODELS (frozen dataclasses)
# =============================================================================


[docs] @dataclass(frozen=True) class EquatorialPoint: """A point in equatorial coordinates (RA/Dec). This is the universal coordinate system for primary directions. All chart positions are converted to this format before calculation. Attributes: name: Name of the point (e.g., "Sun", "ASC") right_ascension: Right ascension in degrees (0-360) declination: Declination in degrees (-90 to +90) """ name: str right_ascension: float declination: float
[docs] @dataclass(frozen=True) class MundanePosition: """A point with full mundane (house-space) context. Knows its position relative to the local horizon/meridian system. Used by the MundaneDirections method for Placidus-style calculations. Attributes: point: The underlying equatorial point meridian_distance: Degrees from MC (-180 to +180, positive = east) semi_arc_diurnal: Degrees of the day arc radius semi_arc_nocturnal: Degrees of the night arc radius is_above_horizon: True if point is above the horizon is_eastern: True if point is on the rising (eastern) side """ point: EquatorialPoint meridian_distance: float semi_arc_diurnal: float semi_arc_nocturnal: float is_above_horizon: bool is_eastern: bool @property def current_semi_arc(self) -> float: """Which semi-arc is currently applicable.""" return ( self.semi_arc_diurnal if self.is_above_horizon else self.semi_arc_nocturnal ) @property def mundane_ratio(self) -> float: """Position as fraction of semi-arc (0=meridian, 1=horizon).""" if self.current_semi_arc == 0: return 0.0 return abs(self.meridian_distance) / self.current_semi_arc
[docs] @dataclass(frozen=True) class DirectionArc: """The result of a primary direction calculation. Attributes: promissor: Name of the moving point significator: Name of the target point arc_degrees: The calculated arc in degrees method: Direction method used ("zodiacal" or "mundane") direction: "direct" or "converse" """ promissor: str significator: str arc_degrees: float method: str direction: str = "direct"
[docs] @dataclass(frozen=True) class DirectionResult: """Complete result of directing one point to another. Attributes: arc: The direction arc details date: Calendar date when the direction perfects (if calculable) age: Age in years when the direction perfects (if calculable) """ arc: DirectionArc date: dt.datetime | None = None age: float | None = None
[docs] @dataclass(frozen=True) class TimeLordPeriod: """A period ruled by a term/bound lord. Used in distributions to track which planetary term the directed Ascendant occupies at different ages. Attributes: ruler: Name of the ruling planet start_date: Date this period begins start_age: Age when this period begins sign: Zodiac sign containing this term end_date: Date this period ends (optional) end_age: Age when this period ends (optional) """ ruler: str start_date: dt.datetime start_age: float sign: str = "" end_date: dt.datetime | None = None end_age: float | None = None
[docs] @dataclass(frozen=True) class TermBoundary: """Represents the starting boundary of a term. Attributes: absolute_degree: Position in the zodiac (0-360) ruler: Planet ruling this term sign: Zodiac sign name """ absolute_degree: float ruler: str sign: str
# ============================================================================= # SECTION 2: PROTOCOLS # =============================================================================
[docs] class DirectionMethod(Protocol): """Protocol for direction calculation methods. Different implementations provide different mathematical approaches: - ZodiacalDirections: Projects onto ecliptic plane (2D) - MundaneDirections: Uses house-space proportions (3D/Placidus) """ @property def method_name(self) -> str: """Name of this direction method.""" ...
[docs] def calculate_arc( self, promissor: EquatorialPoint, significator: EquatorialPoint, latitude: float, ramc: float, ) -> float: """Calculate the arc between promissor and significator. Args: promissor: The moving point significator: The target point latitude: Geographic latitude of the observer ramc: Right Ascension of the Medium Coeli (MC) Returns: Arc in degrees """ ...
[docs] class TimeKey(Protocol): """Protocol for converting arcs to time. Different keys represent different symbolic rates of motion: - Ptolemy: 1 degree = 1 year - Naibod: Based on mean solar motion (~1.0146 years/degree) """ @property def key_name(self) -> str: """Name of this time key.""" ...
[docs] def arc_to_years(self, arc: float) -> float: """Convert arc to years.""" ...
[docs] def arc_to_date(self, arc: float, birth_date: dt.datetime) -> dt.datetime: """Convert arc to calendar date from birth.""" ...
# ============================================================================= # SECTION 3: SPHERICAL MATH (pure functions) # =============================================================================
[docs] def ascensional_difference(declination: float, pole: float) -> float: """Calculate ascensional difference (the 'wobble' from pole tilt). The ascensional difference is how much a point's rising/setting time deviates from 6 hours due to both its own declination and the observer's latitude (pole). Formula: sin(AD) = tan(dec) * tan(pole) Args: declination: Declination of the point in degrees pole: Geographic latitude (or pole of a house) in degrees Returns: Ascensional difference in degrees """ try: rad_dec = math.radians(declination) rad_pole = math.radians(pole) wobble_factor = math.tan(rad_dec) * math.tan(rad_pole) # Clamp to valid range for arcsin wobble_factor = max(-1.0, min(1.0, wobble_factor)) return math.degrees(math.asin(wobble_factor)) except ValueError: # Circumpolar point (never rises/sets) return 0.0
[docs] def semi_arcs(declination: float, latitude: float) -> tuple[float, float]: """Calculate diurnal and nocturnal semi-arcs. A semi-arc is half the arc a point travels above (diurnal) or below (nocturnal) the horizon. At the equator with 0 declination, both are 90. Args: declination: Declination of the point in degrees latitude: Geographic latitude in degrees Returns: Tuple of (diurnal_semi_arc, nocturnal_semi_arc) in degrees """ ad = ascensional_difference(declination, latitude) dsa = 90.0 + ad # Day arc nsa = 90.0 - ad # Night arc (they sum to 180) return dsa, nsa
[docs] def meridian_distance(right_ascension: float, ramc: float) -> float: """Calculate distance from the upper meridian (MC). Positive values indicate the point is east (rising toward MC). Negative values indicate the point is west (setting from MC). Args: right_ascension: RA of the point in degrees ramc: Right Ascension of MC in degrees Returns: Meridian distance in degrees (-180 to +180) """ dist = right_ascension - ramc # Normalize to -180 to +180 while dist > 180: dist -= 360 while dist < -180: dist += 360 return dist
[docs] def oblique_ascension(right_ascension: float, declination: float, pole: float) -> float: """Calculate oblique ascension. Oblique ascension is the RA adjusted for the pole (geographic latitude or house pole). It's used in zodiacal directions. Formula: OA = RA - AD Args: right_ascension: RA in degrees declination: Declination in degrees pole: Pole (latitude) in degrees Returns: Oblique ascension in degrees (0-360) """ ad = ascensional_difference(declination, pole) return (right_ascension - ad) % 360.0
[docs] def get_obliquity(julian_day: float) -> float: """Get the true obliquity of the ecliptic for a given time. Args: julian_day: Julian day number Returns: True obliquity in degrees """ result = swe.calc_ut(julian_day, swe.ECL_NUT) return result[0][0]
[docs] def ecliptic_to_equatorial( longitude: float, latitude: float, obliquity: float ) -> tuple[float, float]: """Convert ecliptic coordinates to equatorial. Args: longitude: Ecliptic longitude in degrees latitude: Ecliptic latitude in degrees (usually 0 for zodiacal points) obliquity: Obliquity of the ecliptic in degrees Returns: Tuple of (right_ascension, declination) in degrees """ ra, dec, _ = swe.cotrans((longitude, latitude, 1.0), -obliquity) return ra, dec
# ============================================================================= # SECTION 4: TIME KEYS # =============================================================================
[docs] class PtolemyKey: """The Classic Key: 1 degree = 1 year. The simplest and oldest time key, attributed to Ptolemy. """ @property def key_name(self) -> str: return "Ptolemy"
[docs] def arc_to_years(self, arc: float) -> float: """1 degree = 1 year.""" return arc
[docs] def arc_to_date(self, arc: float, birth_date: dt.datetime) -> dt.datetime: """Convert arc to date using 1 deg = 1 year.""" days_to_add = arc * 365.25 return birth_date + dt.timedelta(days=days_to_add)
[docs] class NaibodKey: """The Precision Key: Based on mean solar motion. Uses the Sun's mean daily motion of 59'08" (0.9856 deg per day). This makes 1 deg approx 1.0146 years. """ # Solar year in days / degrees in a circle DAYS_PER_DEGREE = 365.25 / 360.0 * 365.25 # ~370.56 @property def key_name(self) -> str: return "Naibod"
[docs] def arc_to_years(self, arc: float) -> float: """Convert arc to years using Naibod rate.""" return arc * (self.DAYS_PER_DEGREE / 365.25)
[docs] def arc_to_date(self, arc: float, birth_date: dt.datetime) -> dt.datetime: """Convert arc to date using Naibod rate.""" days_to_add = arc * self.DAYS_PER_DEGREE return birth_date + dt.timedelta(days=days_to_add)
# ============================================================================= # SECTION 5: DIRECTION METHOD IMPLEMENTATIONS # =============================================================================
[docs] class ZodiacalDirections: """Zodiacal (Regiomontanus-style) primary directions. Projects points onto the ecliptic plane. The significator's pole determines the projection plane. This is the "2D" method. In zodiacal directions, we compare oblique ascensions calculated using the same pole (typically the geographic latitude for ASC). """ @property def method_name(self) -> str: return "zodiacal"
[docs] def calculate_arc( self, promissor: EquatorialPoint, significator: EquatorialPoint, latitude: float, ramc: float, # noqa: ARG002 - required by protocol, used by MundaneDirections ) -> float: """Calculate zodiacal arc via oblique ascension difference. The arc is the difference in oblique ascension between the promissor and significator, using the same pole for both. """ _ = ramc # Unused in zodiacal method, but required by protocol # Use geographic latitude as the pole (for directions to ASC) pole = latitude oa_prom = oblique_ascension( promissor.right_ascension, promissor.declination, pole, ) oa_sig = oblique_ascension( significator.right_ascension, significator.declination, pole, ) arc = (oa_prom - oa_sig) % 360.0 return arc
[docs] class MundaneDirections: """Mundane (Placidus) primary directions. Uses house-space proportions. The promissor must travel to reach the same "mundane ratio" as the significator. This is the "3D" method. The mundane ratio is how far through its current semi-arc a point has traveled (0 = at meridian, 1 = at horizon). """ @property def method_name(self) -> str: return "mundane" def _to_mundane( self, point: EquatorialPoint, latitude: float, ramc: float, ) -> MundanePosition: """Convert equatorial point to mundane position.""" dsa, nsa = semi_arcs(point.declination, latitude) md = meridian_distance(point.right_ascension, ramc) return MundanePosition( point=point, meridian_distance=md, semi_arc_diurnal=dsa, semi_arc_nocturnal=nsa, is_above_horizon=abs(md) <= dsa, is_eastern=md >= 0, )
[docs] def calculate_arc( self, promissor: EquatorialPoint, significator: EquatorialPoint, latitude: float, ramc: float, ) -> float: """Calculate mundane arc using Placidus proportions. The promissor must travel until it reaches the same proportional position within its semi-arc as the significator. """ prom_m = self._to_mundane(promissor, latitude, ramc) sig_m = self._to_mundane(significator, latitude, ramc) # Handle special cases for angles # ASC: ratio = 1.0 (at eastern horizon) # MC: ratio = 0.0 (at upper meridian) if sig_m.is_eastern and sig_m.mundane_ratio >= 0.99: return self._arc_to_eastern_horizon(prom_m) if sig_m.is_above_horizon and sig_m.mundane_ratio <= 0.01: return self._arc_to_upper_meridian(prom_m) # General case: proportional calculation target_ratio = sig_m.mundane_ratio target_md = target_ratio * prom_m.current_semi_arc return abs(prom_m.meridian_distance) - target_md
def _arc_to_upper_meridian(self, p: MundanePosition) -> float: """Calculate arc to reach the MC.""" if p.is_eastern: return abs(p.meridian_distance) return 0.0 # Already past MC def _arc_to_eastern_horizon(self, p: MundanePosition) -> float: """Calculate arc to reach the Ascendant (eastern horizon).""" # Lower East (Q4): Climbing to horizon if p.is_eastern and not p.is_above_horizon: return p.semi_arc_nocturnal - abs(p.meridian_distance) # Lower West (Q3): Must go to IC, then climb if not p.is_eastern and not p.is_above_horizon: dist_to_ic = 180.0 - abs(p.meridian_distance) dist_up_east = p.semi_arc_nocturnal return dist_to_ic + dist_up_east # Upper West (Q2): Must set, go under, then rise if not p.is_eastern and p.is_above_horizon: dist_to_set = p.semi_arc_diurnal - abs(p.meridian_distance) full_night = p.semi_arc_nocturnal * 2.0 return dist_to_set + full_night # Upper East (Q1): Already past ASC return 0.0
# ============================================================================= # SECTION 6: DIRECTIONS ENGINE (main API) # =============================================================================
[docs] class DirectionsEngine: """Primary directions calculation engine. Calculates primary directions between chart points using either zodiacal (2D) or mundane (3D/Placidus) methods. Args: chart: The natal chart to calculate directions for method: Direction method - "zodiacal" (default) or "mundane" time_key: Time key - "naibod" (default) or "ptolemy" Example: >>> engine = DirectionsEngine(chart) >>> result = engine.direct("Sun", "ASC") >>> print(f"Sun to ASC: age {result.age:.1f}") >>> # Compare methods >>> z = DirectionsEngine(chart, method="zodiacal").direct("Sun", "ASC") >>> m = DirectionsEngine(chart, method="mundane").direct("Sun", "ASC") """ METHODS: dict[str, type[DirectionMethod]] = { "zodiacal": ZodiacalDirections, "mundane": MundaneDirections, } TIME_KEYS: dict[str, type[TimeKey]] = { "ptolemy": PtolemyKey, "naibod": NaibodKey, } def __init__( self, chart: CalculatedChart, method: str = "zodiacal", time_key: str = "naibod", ): """Initialize the directions engine. Args: chart: The natal chart method: "zodiacal" or "mundane" time_key: "ptolemy" or "naibod" """ self.chart = chart self._method = self.METHODS[method]() self._time_key = self.TIME_KEYS[time_key]() # Extract chart context self._latitude = chart.location.latitude ramc_obj = chart.get_object("RAMC") if ramc_obj is None: raise ValueError("Chart must have RAMC calculated") self._ramc = ramc_obj.longitude self._obliquity = get_obliquity(chart.datetime.julian_day) self._birth_date = chart.datetime.local_datetime or chart.datetime.utc_datetime def _to_equatorial(self, obj: CelestialPosition) -> EquatorialPoint: """Convert chart position to equatorial coordinates. Handles both planets (which have RA/Dec from ephemeris) and angles (which only have ecliptic longitude). """ # For angles, convert from ecliptic ra, dec = ecliptic_to_equatorial(obj.longitude, 0.0, self._obliquity) return EquatorialPoint(obj.name, ra, dec)
[docs] def direct( self, promissor: str | CelestialPosition, significator: str | CelestialPosition, direction: str = "direct", ) -> DirectionResult: """Calculate a primary direction. Args: promissor: Name or position of moving point (e.g., "Sun") significator: Name or position of target (e.g., "ASC") direction: "direct" or "converse" Returns: DirectionResult with arc, date, and age """ # Resolve names to positions prom_pos = ( self.chart.get_object(promissor) if isinstance(promissor, str) else promissor ) sig_pos = ( self.chart.get_object(significator) if isinstance(significator, str) else significator ) if prom_pos is None: raise ValueError(f"Promissor '{promissor}' not found in chart") if sig_pos is None: raise ValueError(f"Significator '{significator}' not found in chart") # Convert and calculate prom_eq = self._to_equatorial(prom_pos) sig_eq = self._to_equatorial(sig_pos) arc = self._method.calculate_arc(prom_eq, sig_eq, self._latitude, self._ramc) arc_result = DirectionArc( promissor=prom_eq.name, significator=sig_eq.name, arc_degrees=arc, method=self._method.method_name, direction=direction, ) return DirectionResult( arc=arc_result, date=self._time_key.arc_to_date(arc, self._birth_date), age=self._time_key.arc_to_years(arc), )
[docs] def direct_to_angles( self, promissor: str | CelestialPosition ) -> dict[str, DirectionResult]: """Direct a planet to all four angles. Args: promissor: Name or position of the planet Returns: Dictionary mapping angle names to DirectionResults """ angles = ["ASC", "MC", "DSC", "IC"] return {angle: self.direct(promissor, angle) for angle in angles}
[docs] def direct_all_to( self, significator: str | CelestialPosition, planets: list[str] | None = None, ) -> list[DirectionResult]: """Direct multiple planets to a single significator. Args: significator: The target point planets: List of planet names (defaults to traditional planets) Returns: List of DirectionResults sorted by age """ if planets is None: planets = [ "Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", ] results = [] for planet in planets: try: result = self.direct(planet, significator) if result.age is not None and result.age > 0: results.append(result) except ValueError: continue return sorted(results, key=lambda r: r.age or 0)
# ============================================================================= # SECTION 7: DISTRIBUTIONS CALCULATOR (separate class) # =============================================================================
[docs] class DistributionsCalculator: """Calculate term/bound distributions. Distributions track which planetary term (bound) the directed Ascendant occupies at different ages. This creates a timeline of "life chapters" ruled by different planets. Args: chart: The natal chart time_key: Time key - "naibod" (default) or "ptolemy" bound_system: Which bound system to use (default: "egypt") Example: >>> calc = DistributionsCalculator(chart) >>> periods = calc.calculate(years=80) >>> for p in periods: ... print(f"Age {p.start_age:.1f}: {p.ruler} ({p.sign})") """ TIME_KEYS: dict[str, type[TimeKey]] = { "ptolemy": PtolemyKey, "naibod": NaibodKey, } def __init__( self, chart: CalculatedChart, time_key: str = "naibod", bound_system: str = "egypt", ): """Initialize the distributions calculator. Args: chart: The natal chart time_key: "ptolemy" or "naibod" bound_system: Bound system to use ("egypt") """ self.chart = chart self._time_key = self.TIME_KEYS[time_key]() self._bound_system = bound_system # Extract chart context self._latitude = chart.location.latitude ramc_obj = chart.get_object("RAMC") asc_obj = chart.get_object("ASC") if ramc_obj is None: raise ValueError("Chart must have RAMC calculated") if asc_obj is None: raise ValueError("Chart must have ASC calculated") self._ramc = ramc_obj.longitude self._obliquity = get_obliquity(chart.datetime.julian_day) self._birth_date = chart.datetime.local_datetime or chart.datetime.utc_datetime self._asc_degree = asc_obj.longitude # Load term boundaries self._boundaries = self._load_terms() def _load_terms(self) -> list[TermBoundary]: """Load term boundaries from the dignities data.""" boundaries = [] bound_key = f"bound_{self._bound_system}" for i, sign_name in enumerate(DIGNITIES.keys()): sign_offset = i * 30.0 sign_data = DIGNITIES.get(sign_name, {}) bounds = sign_data.get(bound_key, {}) for local_deg, planet in bounds.items(): abs_deg = sign_offset + local_deg boundaries.append( TermBoundary( absolute_degree=abs_deg, ruler=planet, sign=sign_name, ) ) boundaries.sort(key=lambda x: x.absolute_degree) return boundaries def _create_boundary_point(self, degree: float) -> EquatorialPoint: """Create an equatorial point for a zodiacal degree.""" ra, dec = ecliptic_to_equatorial(degree, 0.0, self._obliquity) return EquatorialPoint(f"Term_{degree:.0f}", ra, dec) def _calculate_arc_to_degree(self, target_degree: float) -> float: """Calculate the arc from ASC to a target zodiacal degree.""" asc_point = self._create_boundary_point(self._asc_degree) target_point = self._create_boundary_point(target_degree) # Use zodiacal method for distributions method = ZodiacalDirections() return method.calculate_arc(target_point, asc_point, self._latitude, self._ramc)
[docs] def calculate(self, years: int = 100) -> list[TimeLordPeriod]: """Calculate term distributions for a lifetime. Args: years: Maximum years to calculate Returns: List of TimeLordPeriod objects sorted by age """ # Find current term ruler (term containing the ASC) start_index = 0 current_ruler = "Unknown" current_sign = "" for i, b in enumerate(self._boundaries): if b.absolute_degree > self._asc_degree: start_index = i prev = self._boundaries[i - 1] if i > 0 else self._boundaries[-1] current_ruler = prev.ruler current_sign = prev.sign break periods = [ TimeLordPeriod( ruler=current_ruler, start_date=self._birth_date, start_age=0.0, sign=current_sign, ) ] # Iterate through boundaries current_idx = start_index total_boundaries = len(self._boundaries) while True: target = self._boundaries[current_idx] arc = self._calculate_arc_to_degree(target.absolute_degree) date = self._time_key.arc_to_date(arc, self._birth_date) age = self._time_key.arc_to_years(arc) if age > years: break periods.append( TimeLordPeriod( ruler=target.ruler, start_date=date, start_age=age, sign=target.sign, ) ) current_idx = (current_idx + 1) % total_boundaries # Safety break if len(periods) > 50: break return periods