Source code for stellium.engines.profections

"""
Profection calculation engine.

Profections are a Hellenistic timing technique where the Ascendant (and other
points) move forward one sign per year of life. The planet ruling the profected
sign becomes the "Lord of the Year" - a key focus for that year's themes.

Example:
    >>> from stellium import ChartBuilder
    >>> from stellium.engines.profections import ProfectionEngine
    >>>
    >>> chart = ChartBuilder.from_notable("Albert Einstein").calculate()
    >>> engine = ProfectionEngine(chart)
    >>>
    >>> # Annual profection for age 30
    >>> result = engine.annual(30)
    >>> print(f"Age 30: {result.profected_sign} year, Lord = {result.ruler}")
    >>>
    >>> # Multi-point profections
    >>> results = engine.multi(30, points=["ASC", "Sun", "Moon", "MC"])
    >>> print(f"Lords: {results.lords}")
"""

from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal

from stellium.core.models import CalculatedChart, CelestialPosition
from stellium.engines.dignities import DIGNITIES

# Zodiac signs in order (0-indexed: Aries=0, Taurus=1, ...)
SIGNS = [
    "Aries",
    "Taurus",
    "Gemini",
    "Cancer",
    "Leo",
    "Virgo",
    "Libra",
    "Scorpio",
    "Sagittarius",
    "Capricorn",
    "Aquarius",
    "Pisces",
]


[docs] def sign_to_index(sign: str) -> int: """Convert sign name to index (0-11).""" return SIGNS.index(sign)
[docs] def index_to_sign(index: int) -> str: """Convert index to sign name, wrapping around.""" return SIGNS[index % 12]
[docs] def get_sign_ruler( sign: str, system: Literal["traditional", "modern"] = "traditional" ) -> str: """ Get the planetary ruler of a zodiac sign. Args: sign: The zodiac sign name (e.g., "Aries", "Leo") system: "traditional" (classical rulerships) or "modern" (includes outer planets) Returns: The name of the ruling planet """ if sign not in DIGNITIES: raise ValueError(f"Unknown sign: {sign}") return DIGNITIES[sign][system]["ruler"]
# ============================================================================= # Data Models # =============================================================================
[docs] @dataclass(frozen=True) class ProfectionResult: """ Result of profecting a single point. Contains everything you'd want to know about a profection: what was profected, where it landed, who rules it, and what's there. Attributes: source_point: Name of the profected point ("ASC", "Sun", etc.) source_sign: The sign the point is in natally source_house: The house the point is in natally (1-12) units: How many signs forward the point moved unit_type: Type of profection ("year", "month", "day") profected_house: The house that is activated (1-12) profected_sign: The sign on that house cusp ruler: Traditional ruler of the profected sign (Lord of Year/Month) ruler_position: Natal position of the ruling planet ruler_house: Which house the ruler is in natally ruler_modern: Modern ruler if different from traditional planets_in_house: List of natal planets in the profected house """ # What was profected source_point: str source_sign: str source_house: int # Profection parameters units: int unit_type: str # The result profected_house: int profected_sign: str # The rulers ruler: str ruler_position: CelestialPosition | None ruler_house: int | None ruler_modern: str | None # Activated planets planets_in_house: tuple[CelestialPosition, ...] = field(default_factory=tuple) def __str__(self) -> str: return ( f"Profection: {self.source_point} → House {self.profected_house} " f"({self.profected_sign}), Lord = {self.ruler}" )
[docs] @dataclass(frozen=True) class MultiProfectionResult: """ Profections from multiple points for the same time period. Useful for seeing all the lords at once - e.g., who rules the profected ASC, Sun, Moon, MC, and Fortune for age 30. Attributes: age: The age for these profections date: Optional specific date (for monthly profections) results: Dictionary of ProfectionResult keyed by point name """ age: int date: datetime | None results: dict[str, ProfectionResult] @property def lords(self) -> dict[str, str]: """Get all lords by point name.""" return {point: r.ruler for point, r in self.results.items()} @property def activated_houses(self) -> dict[str, int]: """Get all activated houses by point name.""" return {point: r.profected_house for point, r in self.results.items()} def __str__(self) -> str: lords_str = ", ".join(f"{k}{v}" for k, v in self.lords.items()) return f"Profections (age {self.age}): {lords_str}"
[docs] @dataclass(frozen=True) class ProfectionTimeline: """ A range of profections over time. Useful for seeing the sequence of lords through a span of life, or for displaying in a timeline visualization. Attributes: point: The point being profected (e.g., "ASC") start_age: First age in the timeline end_age: Last age in the timeline entries: List of ProfectionResult for each age """ point: str start_age: int end_age: int entries: tuple[ProfectionResult, ...]
[docs] def lords_sequence(self) -> list[str]: """Get the sequence of lords.""" return [e.ruler for e in self.entries]
[docs] def find_by_lord(self, lord: str) -> list[ProfectionResult]: """Find all years ruled by a specific planet.""" return [e for e in self.entries if e.ruler == lord]
def __str__(self) -> str: lords = self.lords_sequence() return f"Timeline {self.point} ages {self.start_age}-{self.end_age}: {' → '.join(lords)}"
# ============================================================================= # Profection Engine # =============================================================================
[docs] class ProfectionEngine: """ General-purpose profection calculator. Profections move a point forward one sign per unit of time (year, month, day). This engine handles all the complexity of looking up houses, rulers, and finding what planets are activated. Args: chart: The natal chart to profect from house_system: House system to use (default "Whole Sign" - traditional) rulership: Rulership system ("traditional" or "modern") Example: >>> engine = ProfectionEngine(chart) >>> result = engine.annual(30) # Age 30 profection >>> print(result.ruler) # Lord of the Year """ # Default points for multi-point profections DEFAULT_POINTS = ["ASC", "Sun", "Moon", "MC"] def __init__( self, chart: CalculatedChart, house_system: str | None = None, rulership: Literal["traditional", "modern"] = "traditional", ): self.chart = chart self.rulership = rulership # Determine which house system to use # Priority: explicit parameter > Whole Sign if available > chart default if house_system is None: # Prefer Whole Sign (traditional for profections) if available available = list(chart.house_systems.keys()) whole_sign_variants = [h for h in available if "whole" in h.lower()] if whole_sign_variants: house_system = whole_sign_variants[0] else: # Fall back to chart's default house system house_system = chart.default_house_system self.house_system = house_system # Get house cusps for this system # Try the exact name first, then try common variations try: self._houses = chart.get_houses(house_system) except KeyError as err: # Try to find a matching house system available = list(chart.house_systems.keys()) matching = [h for h in available if house_system.lower() in h.lower()] if matching: self._houses = chart.get_houses(matching[0]) self.house_system = matching[0] else: raise ValueError( f"House system '{house_system}' not found. Available: {available}" ) from err # ========================================================================= # Layer 0-1: Core Profection # =========================================================================
[docs] def profect( self, point: str, units: int, unit_type: str = "year", ) -> ProfectionResult: """ Core profection operation. Profects any point forward by N signs and returns everything you'd want to know about the result. Args: point: Point to profect ("ASC", "Sun", "Moon", "MC", etc.) units: Number of signs to move forward (typically age for years) unit_type: Type of profection ("year", "month", "day") Returns: ProfectionResult with full details Example: >>> result = engine.profect("ASC", units=30, unit_type="year") >>> print(f"House {result.profected_house}: {result.profected_sign}") """ # Get source info based on point type if point == "ASC": source_house = 1 source_sign = self._houses.get_sign(1) elif point in ["MC", "DSC", "IC"]: # Angles pos = self.chart.get_object(point) if pos is None: raise ValueError(f"Angle '{point}' not found in chart") source_sign = pos.sign source_house = self.chart.get_house(point, self.house_system) or 1 else: # Planets, lots, etc. pos = self.chart.get_object(point) if pos is None: raise ValueError(f"Point '{point}' not found in chart") source_sign = pos.sign source_house = self.chart.get_house(point, self.house_system) or 1 # Calculate profected house (1-indexed) # Age 0 = house 1 (1st house), Age 1 = house 2, etc. # Formula: ((source_house - 1) + units) % 12 + 1 profected_house = ((source_house - 1 + units) % 12) + 1 # Get the sign on that house cusp profected_sign = self._houses.get_sign(profected_house) # Get rulers ruler = get_sign_ruler(profected_sign, self.rulership) ruler_modern = get_sign_ruler(profected_sign, "modern") if ruler == ruler_modern: ruler_modern = None # Don't repeat if same # Get ruler's natal position and house ruler_position = self.chart.get_object(ruler) ruler_house = self.chart.get_house(ruler, self.house_system) # Find planets in the profected house planets_in_house = tuple( p for p in self.chart.get_planets() if self.chart.get_house(p.name, self.house_system) == profected_house ) return ProfectionResult( source_point=point, source_sign=source_sign, source_house=source_house, units=units, unit_type=unit_type, profected_house=profected_house, profected_sign=profected_sign, ruler=ruler, ruler_position=ruler_position, ruler_house=ruler_house, ruler_modern=ruler_modern, planets_in_house=planets_in_house, )
# ========================================================================= # Layer 2: Convenience Methods # =========================================================================
[docs] def annual(self, age: int, point: str = "ASC") -> ProfectionResult: """ Annual profection for a given age. This is the most common use case: what house and lord are activated for a specific year of life? Args: age: Age in completed years (0 = first year of life) point: Point to profect (default "ASC") Returns: ProfectionResult for that age Example: >>> result = engine.annual(30) >>> print(f"At age 30: {result.profected_sign} year") >>> print(f"Lord of the Year: {result.ruler}") """ if age < 0: raise ValueError("Age cannot be negative") return self.profect(point=point, units=age, unit_type="year")
[docs] def lord_of_year(self, age: int, point: str = "ASC") -> str: """ Convenience: just get the Lord of the Year. Args: age: Age in completed years point: Point to profect (default "ASC") Returns: Name of the ruling planet Example: >>> print(engine.lord_of_year(30)) # "Saturn" """ return self.annual(age, point).ruler
[docs] def monthly( self, age: int, month: int, point: str = "ASC", ) -> ProfectionResult: """ Monthly profection within a given year. Profects forward by (age + month) signs total. Args: age: Age in completed years month: Month within the profection year (0-11) point: Point to profect (default "ASC") Returns: ProfectionResult for that month Example: >>> # 4th month of age 30 year >>> result = engine.monthly(age=30, month=4) >>> print(f"Month 4: {result.profected_sign}") """ if age < 0: raise ValueError("Age cannot be negative") if not 0 <= month <= 11: raise ValueError("Month must be 0-11") total_signs = age + month return self.profect(point=point, units=total_signs, unit_type="month")
[docs] def lord_of_month(self, age: int, month: int, point: str = "ASC") -> str: """ Convenience: just get the Lord of the Month. Args: age: Age in completed years month: Month within profection year (0-11) point: Point to profect (default "ASC") Returns: Name of the ruling planet """ return self.monthly(age, month, point).ruler
# ========================================================================= # Layer 3: Date-Aware Methods (Solar Ingress) # =========================================================================
[docs] def for_date( self, date: datetime | str, point: str = "ASC", include_monthly: bool = True, ) -> ProfectionResult | tuple[ProfectionResult, ProfectionResult]: """ Calculate profections for a specific date. If include_monthly is True, returns both annual and monthly profection. Args: date: Target date (datetime or ISO string) point: Point to profect (default "ASC") include_monthly: Whether to calculate monthly profection too Returns: ProfectionResult, or tuple of (annual, monthly) if include_monthly Example: >>> annual, monthly = engine.for_date("2025-06-15") >>> print(f"Year: {annual.ruler}, Month: {monthly.ruler}") """ if isinstance(date, str): date = datetime.fromisoformat(date) age = self._calculate_age_at_date(date) annual = self.annual(age, point) if not include_monthly: return annual month = self._calculate_month_in_year(date, age) monthly = self.monthly(age, month, point) return annual, monthly
def _calculate_age_at_date(self, date: datetime) -> int: """Calculate completed years since birth.""" birth = self.chart.datetime.local_datetime if birth is None: birth = self.chart.datetime.utc_datetime age = date.year - birth.year if (date.month, date.day) < (birth.month, birth.day): age -= 1 return max(0, age) def _calculate_month_in_year(self, date: datetime, age: int) -> int: """ Calculate which month (0-11) within the profection year. Uses the solar ingress method: each month starts when the transiting Sun enters a new sign. Args: date: Target date age: Age at target date Returns: Month number 0-11 within the profection year """ from stellium.utils.planetary_crossing import find_planetary_crossing from stellium.utils.time import datetime_to_julian_day # Get the birth date and Sun position natal_sun = self.chart.get_object("Sun") if natal_sun is None: raise ValueError("Cannot calculate monthly profection: no Sun in chart") natal_sun_long = natal_sun.longitude birth_jd = self.chart.datetime.julian_day # Find this year's solar return (start of profection year) from stellium.utils.planetary_crossing import find_nth_return if age == 0: year_start_jd = birth_jd else: year_start_jd = find_nth_return("Sun", natal_sun_long, birth_jd, n=age) # Current date as JD current_jd = datetime_to_julian_day(date) # Find each solar ingress after the solar return # Month 0 = from solar return until Sun enters next sign # Month 1 = from first ingress to second ingress, etc. # Starting sign (at solar return) start_sign_index = int(natal_sun_long // 30) month = 0 search_jd = year_start_jd for m in range(12): # Find when Sun enters the next sign next_sign_index = (start_sign_index + 1 + m) % 12 next_sign_degree = next_sign_index * 30 try: ingress_jd = find_planetary_crossing( "Sun", next_sign_degree, search_jd, direction=1 ) if ingress_jd > current_jd: # Current date is before this ingress, so we're in month m break month = m + 1 search_jd = ingress_jd + 0.1 # Move past ingress except ValueError: # Shouldn't happen within a year, but be safe break return min(month, 11) # ========================================================================= # Layer 4: Multi-Point Methods # =========================================================================
[docs] def multi( self, age: int, points: list[str] | None = None, ) -> MultiProfectionResult: """ Profect multiple points at once. Useful for seeing all the lords for a given age - who rules the profected ASC, Sun, Moon, MC? Args: age: Age in completed years points: Points to profect (default: ASC, Sun, Moon, MC) Returns: MultiProfectionResult with all profections Example: >>> results = engine.multi(30) >>> print(results.lords) # {"ASC": "Saturn", "Sun": "Mars", ...} """ if points is None: points = self.DEFAULT_POINTS results = {point: self.annual(age, point) for point in points} return MultiProfectionResult(age=age, date=None, results=results)
[docs] def multi_for_date( self, date: datetime | str, points: list[str] | None = None, ) -> MultiProfectionResult: """ Profect multiple points for a specific date. Args: date: Target date points: Points to profect (default: ASC, Sun, Moon, MC) Returns: MultiProfectionResult with date attached """ if isinstance(date, str): date = datetime.fromisoformat(date) age = self._calculate_age_at_date(date) result = self.multi(age, points) # Return with date attached return MultiProfectionResult(age=age, date=date, results=result.results)
# ========================================================================= # Layer 5: Timeline # =========================================================================
[docs] def timeline( self, start_age: int, end_age: int, point: str = "ASC", ) -> ProfectionTimeline: """ Generate profections for a range of ages. Useful for seeing the sequence of lords through life, or for timeline visualizations. Args: start_age: First age (inclusive) end_age: Last age (inclusive) point: Point to profect (default "ASC") Returns: ProfectionTimeline with all entries Example: >>> timeline = engine.timeline(25, 35) >>> for entry in timeline.entries: ... print(f"Age {entry.units}: {entry.ruler}") """ if start_age < 0: raise ValueError("start_age cannot be negative") if end_age < start_age: raise ValueError("end_age must be >= start_age") entries = tuple( self.annual(age, point) for age in range(start_age, end_age + 1) ) return ProfectionTimeline( point=point, start_age=start_age, end_age=end_age, entries=entries, )