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.engines.dignities import DIGNITIES

PLANET_PERIODS = {
    "Moon": 25,
    "Mercury": 20,
    "Venus": 8,
    "Sun": 19,
    "Mars": 15,
    "Jupiter": 12,
    "Saturn": 27,
}


[docs] class ZodiacalReleasingEngine: """Calculate Zodiacal Releasing periods.""" def __init__( self, chart: CalculatedChart, lot: str = "Part of Fortune", max_level: int = 4, lifespan: int = 100, method: str = "valens", ) -> None: self.chart = chart self.lot = lot self.max_level = max_level self.lifespan = lifespan self.method = method # Can be "valens" or "fractal" self.planet_periods = PLANET_PERIODS self.sign_periods = { sign: PLANET_PERIODS[info["traditional"]["ruler"]] for sign, info in DIGNITIES.items() } self.signs = list(self.sign_periods.keys()) self.total_cycle_period = sum(self.sign_periods.values()) # 208 self.lot_position = self._get_lot_position() self.lot_sign = self.lot_position.sign self.angular_signs = self._get_angular_signs() self._setup_quality_lookups() # Get all sect-relevant placements def _get_lot_position(self) -> CelestialPosition: """Get the base lot position.""" if self.lot not in ARABIC_PARTS_CATALOG: raise ValueError( "Provided Lot name unknown. Try 'Part of Fortune', 'Part of Spirit', or others." ) else: # Check if lot has already been calculated lot_options = [x for x in self.chart.positions if x.name == self.lot] if lot_options: lot_pos = lot_options[0] else: # Calculate just this lot calculator = ArabicPartsCalculator([self.lot]) lot_pos = calculator.calculate( self.chart.datetime, self.chart.location, self.chart.positions, self.chart.house_systems, self.chart.house_placements, )[0] return lot_pos 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 that are angular to the Lot.""" lot_sign_index = self.signs.index(self.lot_sign) return { self.signs[lot_sign_index]: 1, self.signs[(lot_sign_index + 3) % 12]: 4, self.signs[(lot_sign_index + 6) % 12]: 7, self.signs[(lot_sign_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 = 208 * 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.""" level_multipliers = { 1: 365.25, # Years 2: 30.437, # Months 3: 1.0146, # Days 4: 0.0417, # Hours } 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 * level_multipliers[level] # 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 the transition is a "loosing of the bond" jump to the opposite sign of the next Returns: Name of "next" sign """ consecutive_sign = self.signs[(self.signs.index(current_sign) + 1) % 12] if jump: return self.signs[(self.signs.index(consecutive_sign) + 6) % 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 = 208 years in days (so scaling = identity) base_duration = self.total_cycle_period * 365.25 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, ) -> None: self.lots = lots self.engine = engine self.max_level = max_level self.lifespan = lifespan @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 ) results[lot] = lot_engine.build_timeline() return results