Source code for stellium.engines.releasing

"""Implementation of standard Zodiacal Releasing system."""

import datetime as dt

from stellium.components.arabic_parts import ARABIC_PARTS_CATALOG, ArabicPartsCalculator
from stellium.core.models import (
    CalculatedChart,
    CelestialPosition,
    ZRPeriod,
    ZRTimeline,
)
from stellium.core.planetary_years import LEAST_YEARS
from stellium.engines.dignities import DIGNITIES

# Each sign receives the "least (minor) years" of its (traditional) ruler as its
# base period. Canonical values live in stellium.core.planetary_years; this alias
# preserves the historical name/import path.
PLANET_PERIODS = LEAST_YEARS

# Per-sign departures from the ruler's minor years. Zodiacal Releasing gives
# Capricorn a reduction to 27 while Aquarius (also Saturn-ruled) keeps the full
# 30; with this override the full cycle totals 211 years. The reason for
# Capricorn's 27 is *not preserved* in Valens — he assigns the number without
# explanation. The popular "27 = 1/4 of the Moon's greater years (108/4)" story
# is folk-etymology (the arithmetic is coincidental; no source in the tradition
# connects it to the ZR sign-periods). The sourced symbolic association is
# Manwaring's "27 lunar mansions," not a quarter of greater years.
SIGN_PERIOD_OVERRIDES = {"Capricorn": 27}


[docs] class ZodiacalReleasingEngine: """Calculate Zodiacal Releasing periods. **The defaults are the "Brennan / Modern Standard" configuration** — the most-tested, most-documented setup, matching the free calculators (Astro-Seek, zodiacalreleasing.net) and the major software (Solar Fire, Delphic Oracle) that users cross-check against: 360-day ideal years, Capricorn 27 / Aquarius 30, loosing of the bond to the opposite sign after a full 12-sign circuit, truncation at parent boundaries, the same-sign Spirit rule, sect-reversed lots, and peaks measured from the Lot of Fortune. So ``ZodiacalReleasingEngine(chart)`` needs no preset — it *is* the standard. The individual conventions are exposed as constructor arguments (``year_length``, ``capricorn_years``, ``loosing_target``) for the minority variants. ``method="fractal"`` selects a pure recursive subdivision with no loosing of the bond — an **experimental, non-canonical** mode with no traditional authority; use it only for comparison. """ def __init__( self, chart: CalculatedChart, lot: str = "Part of Fortune", max_level: int = 4, lifespan: int = 100, method: str = "valens", year_length: float = 360.0, capricorn_years: int = 27, loosing_target: str = "opposite", ) -> None: """ Args: chart: The calculated chart. lot: Which lot to release from ("Part of Fortune", "Part of Spirit", "Part of Eros (Love)", ...). Peaks are always measured from Fortune regardless of this choice. max_level: Deepest subdivision level to compute (1-4). lifespan: Years of L1 timeline to generate. method: "valens" (loosing of the bond + truncation, the canonical default) or "fractal" (pure recursive subdivision, non-canonical). year_length: Days per "year" for period distribution. Valens/Brennan use the 360-day ideal year (default); pass 365.25 for the civil-year variant. Each level below L1 is exactly 1/12 of its parent, so a month is year_length/12, etc. capricorn_years: Capricorn's period. 27 (default, the documented Valens reduction) or 30 (strict Saturn minor years). loosing_target: Where the loosing of the bond jumps: "opposite" (default, the sign opposite the parent -- Valens' preference) or "trine" (the sign some of his contemporaries used instead). """ if loosing_target not in ("opposite", "trine"): raise ValueError( f"loosing_target must be 'opposite' or 'trine', got {loosing_target!r}" ) self.chart = chart self.lot = lot self.max_level = max_level self.lifespan = lifespan self.method = method # Can be "valens" or "fractal" self.year_length = year_length self.loosing_target = loosing_target self.planet_periods = PLANET_PERIODS self.sign_periods = { sign: PLANET_PERIODS[info["traditional"]["ruler"]] for sign, info in DIGNITIES.items() } # Apply documented per-sign departures from ruler minor years. Capricorn # is the only one (27 by default; 30 = strict Saturn minor years). self.sign_periods.update(SIGN_PERIOD_OVERRIDES) self.sign_periods["Capricorn"] = capricorn_years self.signs = list(self.sign_periods.keys()) self.total_cycle_period = sum(self.sign_periods.values()) # 211 (Cap 27) self.lot_position = self._get_lot_position(self.lot) self.lot_sign = self.lot_position.sign # Peaks are angular to the Lot of Fortune regardless of the release lot, # so resolve Fortune's sign separately (reuse it if we're releasing from # Fortune). if self.lot == "Part of Fortune": self.fortune_sign = self.lot_sign else: self.fortune_sign = self._get_lot_position("Part of Fortune").sign # Same-sign Spirit rule (Valens): when releasing from Spirit and Spirit # falls in the same sign as Fortune (near New/Full Moon births), release # Spirit from the next sign instead. Fortune is left in place. if self.lot == "Part of Spirit" and self.lot_sign == self.fortune_sign: self.lot_sign = self._next_sign(self.lot_sign) self.angular_signs = self._get_angular_signs() self._setup_quality_lookups() # Get all sect-relevant placements def _get_lot_position(self, lot_name: str) -> CelestialPosition: """Get the position of a named lot, computing it if not already present.""" if lot_name not in ARABIC_PARTS_CATALOG: raise ValueError( f"Unknown lot '{lot_name}'. Try 'Part of Fortune', " "'Part of Spirit', or others." ) # Reuse the lot if it was already calculated on the chart lot_options = [x for x in self.chart.positions if x.name == lot_name] if lot_options: return lot_options[0] # Otherwise calculate just this lot calculator = ArabicPartsCalculator([lot_name]) return calculator.calculate( self.chart.datetime, self.chart.location, self.chart.positions, self.chart.house_systems, self.chart.house_placements, )[0] def _setup_quality_lookups(self) -> None: """Build fast lookups for planet roles and sign contents.""" sect = self.chart.sect # 1. Define Roles based on Sect # Format: (PlanetName, RoleName, ScoreModifier) if sect == "day": mapping = [ ("Jupiter", "sect_benefic", 2), ("Venus", "contrary_benefic", 1), ("Saturn", "sect_malefic", -1), # Constructive difficulty ("Mars", "contrary_malefic", -2), # Destructive difficulty ("Sun", "sect_light", 1), ("Moon", "contrary_light", 0), ] else: # Night mapping = [ ("Venus", "sect_benefic", 2), ("Jupiter", "contrary_benefic", 1), ("Mars", "sect_malefic", -1), ("Saturn", "contrary_malefic", -2), ("Moon", "sect_light", 1), ("Sun", "contrary_light", 0), ] # 2. Build Lookup Maps self.ruler_roles = {} # { "Jupiter": ("sect_benefic", 2) } self.sign_contents = {} # { "Pisces": [("sect_benefic", 2)] } for planet_name, role, score in mapping: # A. Ruler Lookup self.ruler_roles[planet_name] = (role, score) # B. Presence Lookup # Find where this planet is in the chart planet_pos = next( (p for p in self.chart.positions if p.name == planet_name), None ) if planet_pos: if planet_pos.sign not in self.sign_contents: self.sign_contents[planet_pos.sign] = [] self.sign_contents[planet_pos.sign].append((role, score)) def _get_period_duration(self, sign: str, parent_duration: float) -> float: sign_period = self.sign_periods[sign] return parent_duration * (sign_period / self.total_cycle_period) def _get_angular_signs(self) -> dict[str, int]: """Get signs angular to the Lot of Fortune. Peak/angular periods are always measured from Fortune, regardless of which lot is being released (Valens/Brennan). The 10th from Fortune is the major peak. """ fortune_index = self.signs.index(self.fortune_sign) return { self.signs[fortune_index]: 1, self.signs[(fortune_index + 3) % 12]: 4, self.signs[(fortune_index + 6) % 12]: 7, self.signs[(fortune_index + 9) % 12]: 10, # Peak! } def _calculate_periods( self, level: int, start_sign: str, start_date: dt.datetime, total_duration: float, ) -> list[ZRPeriod]: """ Unified period calculator for all levels. L1: total_duration_days = 211 * 365.25, loops until lifespan L2+: total_duration_days = parent.length_days, loops exactly 12 """ periods = [] current_sign = start_sign current_date = start_date signs_processed = 0 while True: sign_period = self.sign_periods[current_sign] period_days = total_duration * (sign_period / self.total_cycle_period) end_date = current_date + dt.timedelta(days=period_days) angle = self.angular_signs.get(current_sign) # === Quality calculation === period_score = 0 # Analyze ruler period_ruler_name = DIGNITIES[current_sign]["traditional"]["ruler"] ruler_info = self.ruler_roles.get(period_ruler_name) ruler_role_name = None if ruler_info: ruler_role_name, r_score = ruler_info period_score += r_score # 2. Analyze Planets Present in the Sign present_roles_list = [] if current_sign in self.sign_contents: for role, p_score in self.sign_contents[current_sign]: present_roles_list.append(role) # Presence is usually "louder" than rulership, so we might weight it period_score += p_score # 3. Angularity Boost (Optional) # Peak periods amplify the good AND the bad if angle == 10: # If bad score, make it worse. If good score, make it better. if period_score < 0: period_score -= 1 if period_score > 0: period_score += 1 periods.append( ZRPeriod( level=level, sign=current_sign, ruler=DIGNITIES[current_sign]["traditional"]["ruler"], start=current_date, end=end_date, length_days=period_days, angle_from_lot=angle, is_angular=angle is not None, is_peak=angle == 10, is_loosing_bond=False, # Qualitative fields ruler_role=ruler_role_name, tenant_roles=present_roles_list, score=period_score, ) ) current_date = end_date current_sign = self._next_sign(current_sign) signs_processed += 1 # Exit conditions if level == 1: # L1: continue until lifespan exceeded age_years = ( current_date - self.chart.datetime.utc_datetime ).days / 365.25 if age_years > self.lifespan: break else: # L2+: exactly one cycle (12 signs) if signs_processed >= 12: break return periods def _calculate_periods_valens( self, level: int, start_sign: str, start_date: dt.datetime, total_duration: float, ) -> list[ZRPeriod]: """Calculate the traditional Valens-style period traversal with loosing of the bond.""" # Each level below L1 is exactly 1/12 of its parent, counted in # year_length-day years (360 by default): L1 = year, L2 = month # (year/12), L3 = year/144, L4 = year/1728. unit_days = self.year_length / (12 ** (level - 1)) periods = [] current_sign = start_sign current_date = start_date signs_processed = 0 time_passed = 0.0 while True: # Calculate the "ideal" duration for this sign period sign_period = self.sign_periods[current_sign] ideal_period_days = sign_period * unit_days # Check remaining budget (for L2+) final_duration = ideal_period_days is_truncated = False if level > 1: remaining_time = total_duration - time_passed # Floating point precision check: if we are practically out of time, stop. if remaining_time <= 0.01: break # TRUNCATION LOGIC: # If this period would go over the parent's limit, cut it short. if ideal_period_days > remaining_time: final_duration = remaining_time is_truncated = True # Calculate the end date end_date = current_date + dt.timedelta(days=final_duration) angle = self.angular_signs.get(current_sign) # === Quality calculation === period_score = 0 # Analyze ruler period_ruler_name = DIGNITIES[current_sign]["traditional"]["ruler"] ruler_info = self.ruler_roles.get(period_ruler_name) ruler_role_name = None if ruler_info: ruler_role_name, r_score = ruler_info period_score += r_score # 2. Analyze Planets Present in the Sign present_roles_list = [] if current_sign in self.sign_contents: for role, p_score in self.sign_contents[current_sign]: present_roles_list.append(role) # Presence is usually "louder" than rulership, so we might weight it period_score += p_score # 3. Angularity Boost (Optional) # Peak periods amplify the good AND the bad if angle == 10: # If bad score, make it worse. If good score, make it better. if period_score < 0: period_score -= 1 if period_score > 0: period_score += 1 periods.append( ZRPeriod( level=level, sign=current_sign, ruler=DIGNITIES[current_sign]["traditional"]["ruler"], start=current_date, end=end_date, length_days=final_duration, angle_from_lot=angle, is_angular=angle is not None, is_peak=angle == 10, is_loosing_bond=signs_processed == 12, # Qualitative fields ruler_role=ruler_role_name, tenant_roles=present_roles_list, score=period_score, ) ) # Break if we just truncated (time ran out) if is_truncated: break time_passed += final_duration current_date = end_date # Loosing of the bond after first cycle -- jump to opposite current_sign = self._next_sign(current_sign, jump=signs_processed == 11) signs_processed += 1 # Exit conditions if level == 1: # L1: continue until lifespan exceeded age_years = ( current_date - self.chart.datetime.utc_datetime ).days / 365.25 if age_years > self.lifespan: break return periods def _next_sign(self, current_sign: str, jump: bool = False) -> str: """Calculate the next sign in the cycle. Args: current_sign: current sign name jump: If True this is a "loosing of the bond" transition, which jumps to the sign opposite the parent (or the trine, per loosing_target) instead of continuing zodiacally. Returns: Name of "next" sign """ consecutive_sign = self.signs[(self.signs.index(current_sign) + 1) % 12] if jump: # consecutive_sign is the parent's sign here; jump to its opposite # (6 signs) or trine (4 signs) per loosing_target. offset = 6 if self.loosing_target == "opposite" else 4 return self.signs[(self.signs.index(consecutive_sign) + offset) % 12] return consecutive_sign
[docs] def calculate_all_periods(self) -> dict[int, list[ZRPeriod]]: """Build all periods for all levels""" all_periods: dict[int, list[ZRPeriod]] = {} # Set the calculation function used calc_fn = ( self._calculate_periods if self.method == "fractal" else self._calculate_periods_valens ) # L1: base duration = full cycle in days (so per-sign scaling = identity) base_duration = self.total_cycle_period * self.year_length all_periods[1] = calc_fn( level=1, start_sign=self.lot_sign, start_date=self.chart.datetime.utc_datetime, total_duration=base_duration, ) # L2+: iterate parent periods for level in range(2, self.max_level + 1): all_periods[level] = [] for parent in all_periods[level - 1]: subperiods = calc_fn( level=level, start_sign=parent.sign, start_date=parent.start, total_duration=parent.length_days, ) all_periods[level].extend(subperiods) return all_periods
[docs] def build_timeline(self) -> ZRTimeline: """Build complete timeline with all periods.""" all_periods = self.calculate_all_periods() return ZRTimeline( lot=self.lot, lot_sign=self.lot_sign, birth_date=self.chart.datetime.utc_datetime, periods=all_periods, max_level=self.max_level, )
[docs] class ZodiacalReleasingAnalyzer: """Calculate Zodiacal Releasing timeline and periods.""" def __init__( self, lots: list[str], engine=ZodiacalReleasingEngine, max_level: int = 4, lifespan: int = 100, year_length: float = 360.0, capricorn_years: int = 27, loosing_target: str = "opposite", ) -> None: self.lots = lots self.engine = engine self.max_level = max_level self.lifespan = lifespan self.year_length = year_length self.capricorn_years = capricorn_years self.loosing_target = loosing_target @property def analyzer_name(self) -> str: return "ZodiacalReleasing" @property def metadata_name(self) -> str: return "zodiacal_releasing"
[docs] def analyze(self, chart: CalculatedChart) -> dict: """Add zodiacial releasing timeline to metadata. Args: chart: Chart to analyze Returns: Dict of {lot name: ZRTimeline} """ results = {} for lot in self.lots: lot_engine = self.engine( chart, lot, max_level=self.max_level, lifespan=self.lifespan, year_length=self.year_length, capricorn_years=self.capricorn_years, loosing_target=self.loosing_target, ) results[lot] = lot_engine.build_timeline() return results