Source code for stellium.returns.builder

"""ReturnBuilder: Fluent builder for planetary return charts.

A "return" is a chart cast for the moment when a transiting planet
returns to its exact natal position. Common returns include:
- Solar Return: Sun returns to natal Sun position (~birthday)
- Lunar Return: Moon returns to natal Moon position (~monthly)
- Saturn Return: Saturn returns to natal Saturn (~age 29, 58)

ReturnBuilder wraps ChartBuilder using composition, delegating
all chainable configuration methods while handling the return-specific
calculations internally.
"""

from __future__ import annotations

import datetime as dt
from dataclasses import dataclass
from typing import TYPE_CHECKING

import pytz

from stellium.core.builder import ChartBuilder
from stellium.core.models import CalculatedChart, ChartLocation
from stellium.core.protocols import (
    AspectEngine,
    ChartAnalyzer,
    ChartComponent,
    EphemerisEngine,
    HouseSystemEngine,
    OrbEngine,
)
from stellium.utils.planetary_crossing import (
    find_nth_return,
    find_return_near_date,
)
from stellium.utils.time import datetime_to_julian_day, julian_day_to_datetime

if TYPE_CHECKING:
    from stellium.core.config import CalculationConfig


[docs] @dataclass class ReturnInfo: """Internal data about a calculated return moment.""" return_jd: float return_datetime: dt.datetime natal_longitude: float return_number: int | None # None for "near_date" returns location: ChartLocation
[docs] class ReturnBuilder: """ Fluent builder for planetary return charts. Uses composition: wraps ChartBuilder rather than inheriting from it. This allows us to: - Lazily calculate the return moment before building the inner chart - Inject return-specific metadata into the final chart - Delegate all chainable methods without tight coupling Usage: >>> # Solar Return for 2025 >>> sr = ReturnBuilder.solar(natal_chart, 2025).calculate() >>> >>> # Lunar Return near a date >>> lr = ReturnBuilder.lunar(natal_chart, near_date="2025-03-15").calculate() >>> >>> # First Saturn Return >>> saturn = ReturnBuilder.planetary(natal_chart, "Saturn", occurrence=1).calculate() >>> >>> # Relocated Solar Return >>> sr_relocated = ( ... ReturnBuilder.solar(natal_chart, 2025, location="Tokyo, Japan") ... .calculate() ... ) """ def __init__( self, natal: CalculatedChart, planet: str, *, year: int | None = None, near_date: str | dt.datetime | None = None, occurrence: int | None = None, location: str | tuple[float, float] | ChartLocation | None = None, ) -> None: """ Initialize ReturnBuilder (use factory methods instead). Args: natal: The natal chart to find returns for planet: Planet name (e.g., "Sun", "Moon", "Saturn") year: For annual returns (Solar), the year to calculate near_date: Find return nearest to this date occurrence: Find the Nth return (1 = first, 2 = second) location: Override location (for relocated returns) """ self._natal = natal self._planet = planet self._year = year self._near_date = near_date self._occurrence = occurrence self._location_override = location # Inner builder (lazily created) self._inner_builder: ChartBuilder | None = None # Deferred configuration (applied when inner builder is created) self._deferred_ephemeris: EphemerisEngine | None = None self._deferred_house_systems: list[HouseSystemEngine] | None = None self._deferred_aspect_engine: AspectEngine | None = None self._deferred_orb_engine: OrbEngine | None = None self._deferred_components: list[ChartComponent] = [] self._deferred_analyzers: list[ChartAnalyzer] = [] self._deferred_config: CalculationConfig | None = None # ---- Factory Methods ----
[docs] @classmethod def solar( cls, natal: CalculatedChart, year: int, *, location: str | tuple[float, float] | ChartLocation | None = None, ) -> ReturnBuilder: """ Create a Solar Return builder. A Solar Return is the chart cast for when the Sun returns to its exact natal position. This happens approximately on your birthday each year (but the exact time varies). Args: natal: The natal chart year: Year to calculate the return for location: Override location (for relocated Solar Return) Returns: ReturnBuilder configured for Solar Return Example: >>> sr_2025 = ReturnBuilder.solar(natal, 2025).calculate() """ if not isinstance(year, int): raise TypeError(f"year must be an integer, got {type(year).__name__}") natal_year = natal.datetime.utc_datetime.year if year < natal_year: raise ValueError( f"Year {year} is before the natal birth year ({natal_year})" ) return cls(natal, "Sun", year=year, location=location)
[docs] @classmethod def lunar( cls, natal: CalculatedChart, *, near_date: str | dt.datetime | None = None, occurrence: int | None = None, location: str | tuple[float, float] | ChartLocation | None = None, ) -> ReturnBuilder: """ Create a Lunar Return builder. A Lunar Return is the chart cast for when the Moon returns to its exact natal position. This happens approximately every 27.3 days. Args: natal: The natal chart near_date: Find the return nearest to this date (default: now) occurrence: Find the Nth return after birth (1 = first) location: Override location Returns: ReturnBuilder configured for Lunar Return Example: >>> # Lunar Return nearest to March 15, 2025 >>> lr = ReturnBuilder.lunar(natal, near_date="2025-03-15").calculate() >>> >>> # The 100th Lunar Return >>> lr_100 = ReturnBuilder.lunar(natal, occurrence=100).calculate() """ # Default to current time if neither specified if near_date is None and occurrence is None: near_date = dt.datetime.now(dt.UTC) if occurrence is not None and ( not isinstance(occurrence, int) or occurrence < 1 ): raise ValueError(f"occurrence must be a positive integer, got {occurrence}") return cls( natal, "Moon", near_date=near_date, occurrence=occurrence, location=location )
[docs] @classmethod def planetary( cls, natal: CalculatedChart, planet: str, *, near_date: str | dt.datetime | None = None, occurrence: int | None = None, location: str | tuple[float, float] | ChartLocation | None = None, ) -> ReturnBuilder: """ Create a planetary return builder for any planet. Args: natal: The natal chart planet: Planet name ("Saturn", "Jupiter", "Mars", etc.) near_date: Find the return nearest to this date occurrence: Find the Nth return (1 = first) location: Override location Returns: ReturnBuilder configured for the specified planetary return Example: >>> # First Saturn Return (~age 29) >>> sr1 = ReturnBuilder.planetary(natal, "Saturn", occurrence=1).calculate() >>> >>> # Jupiter Return nearest to 2025 >>> jr = ReturnBuilder.planetary( ... natal, "Jupiter", near_date="2025-06-01" ... ).calculate() """ if near_date is None and occurrence is None: raise ValueError( "Must specify either near_date or occurrence for planetary returns" ) if not isinstance(planet, str) or not planet: raise TypeError("planet must be a non-empty string") if occurrence is not None and ( not isinstance(occurrence, int) or occurrence < 1 ): raise ValueError(f"occurrence must be a positive integer, got {occurrence}") return cls( natal, planet, near_date=near_date, occurrence=occurrence, location=location )
# ---- Delegated Configuration Methods ---- # These mirror ChartBuilder's fluent API but store config for later
[docs] def with_ephemeris(self, engine: EphemerisEngine) -> ReturnBuilder: """Set the ephemeris engine.""" self._deferred_ephemeris = engine return self
[docs] def with_house_systems(self, engines: list[HouseSystemEngine]) -> ReturnBuilder: """Set the house system engines.""" self._deferred_house_systems = engines return self
[docs] def add_house_system(self, engine: HouseSystemEngine) -> ReturnBuilder: """Add an additional house system.""" if self._deferred_house_systems is None: self._deferred_house_systems = [] self._deferred_house_systems.append(engine) return self
[docs] def with_aspects(self, engine: AspectEngine | None = None) -> ReturnBuilder: """Set the aspect engine.""" self._deferred_aspect_engine = engine return self
[docs] def with_orbs(self, engine: OrbEngine | None = None) -> ReturnBuilder: """Set the orb engine.""" self._deferred_orb_engine = engine return self
[docs] def add_component(self, component: ChartComponent) -> ReturnBuilder: """Add a calculation component.""" self._deferred_components.append(component) return self
[docs] def add_analyzer(self, analyzer: ChartAnalyzer) -> ReturnBuilder: """Add a chart analyzer.""" self._deferred_analyzers.append(analyzer) return self
[docs] def with_config(self, config: CalculationConfig) -> ReturnBuilder: """Set the calculation configuration.""" self._deferred_config = config return self
# ---- Main Calculation ----
[docs] def calculate(self) -> CalculatedChart: """ Calculate the return chart. This: 1. Finds the exact moment of the planetary return 2. Creates a ChartBuilder for that moment 3. Applies any deferred configuration 4. Injects return metadata 5. Returns the calculated chart Returns: CalculatedChart with return metadata in chart.metadata """ self._ensure_builder() assert self._inner_builder is not None return self._inner_builder.calculate()
# ---- Internal Methods ---- def _ensure_builder(self) -> None: """Lazily create the inner ChartBuilder with return datetime.""" if self._inner_builder is not None: return # Calculate the return moment return_info = self._calculate_return_info() # Resolve location location = self._resolve_location(return_info) # Create chart name natal_name = self._natal.metadata.get("name", "Chart") chart_name = f"{natal_name} - {self._planet} Return" if self._year: chart_name = f"{natal_name} - {self._year} {self._planet} Return" # Create inner builder self._inner_builder = ChartBuilder.from_details( return_info.return_datetime, location, name=chart_name, ) # Apply deferred configuration self._apply_deferred_config() # Inject return metadata via the hook we added self._inner_builder._extra_metadata = { # type: ignore[attr-defined] "chart_type": "return", "return_planet": self._planet, "natal_planet_longitude": return_info.natal_longitude, "return_julian_day": return_info.return_jd, } if return_info.return_number is not None: self._inner_builder._extra_metadata["return_number"] = ( return_info.return_number ) # type: ignore[attr-defined] def _calculate_return_info(self) -> ReturnInfo: """Calculate the return moment based on configuration.""" # Get natal planet position natal_planet = self._natal.get_object(self._planet) if natal_planet is None: raise ValueError(f"Planet '{self._planet}' not found in natal chart") natal_longitude = natal_planet.longitude birth_jd = self._natal.datetime.julian_day return_jd: float return_number: int | None = None if self._year is not None: # Solar return: find return in specified year # Start searching from Jan 1 of that year search_start = datetime_to_julian_day( dt.datetime(self._year, 1, 1, tzinfo=pytz.UTC) ) return_jd = find_return_near_date( self._planet, natal_longitude, search_start ) elif self._occurrence is not None: # Nth return after birth return_jd = find_nth_return( self._planet, natal_longitude, birth_jd, self._occurrence ) return_number = self._occurrence elif self._near_date is not None: # Return nearest to specified date if isinstance(self._near_date, str): # Parse string date from dateutil.parser import parse target_dt = parse(self._near_date) if target_dt.tzinfo is None: target_dt = pytz.UTC.localize(target_dt) else: target_dt = self._near_date if target_dt.tzinfo is None: target_dt = pytz.UTC.localize(target_dt) target_jd = datetime_to_julian_day(target_dt) return_jd = find_return_near_date(self._planet, natal_longitude, target_jd) else: raise ValueError("Must specify year, occurrence, or near_date") # Convert JD back to datetime return_datetime = julian_day_to_datetime(return_jd) return ReturnInfo( return_jd=return_jd, return_datetime=return_datetime, natal_longitude=natal_longitude, return_number=return_number, location=self._natal.location, # Default to natal location ) def _resolve_location(self, return_info: ReturnInfo) -> str | tuple[float, float]: """Resolve the location for the return chart.""" if self._location_override is None: # Use natal location loc = self._natal.location return (loc.latitude, loc.longitude) if isinstance(self._location_override, ChartLocation): return (self._location_override.latitude, self._location_override.longitude) # String or tuple - pass through to ChartBuilder.from_details return self._location_override def _apply_deferred_config(self) -> None: """Apply any deferred configuration to the inner builder.""" assert self._inner_builder is not None if self._deferred_ephemeris is not None: self._inner_builder.with_ephemeris(self._deferred_ephemeris) if self._deferred_house_systems is not None: self._inner_builder.with_house_systems(self._deferred_house_systems) if self._deferred_aspect_engine is not None: self._inner_builder.with_aspects(self._deferred_aspect_engine) else: # Default: enable aspects self._inner_builder.with_aspects() if self._deferred_orb_engine is not None: self._inner_builder.with_orbs(self._deferred_orb_engine) for component in self._deferred_components: self._inner_builder.add_component(component) for analyzer in self._deferred_analyzers: self._inner_builder.add_analyzer(analyzer) if self._deferred_config is not None: self._inner_builder.with_config(self._deferred_config)