Source code for stellium.engines.patterns

"""
Aspect Pattern Analyzer Engine.

Finds major aspect patterns like Grand Trines, T-Squares, Yods, Kites,
Grand Crosses, and Stelliums.
This module implements the "Analyzer" protocol.
"""

from itertools import combinations

from stellium.core.models import (
    Aspect,
    AspectPattern,
    CalculatedChart,
    CelestialPosition,
    ObjectType,
)
from stellium.engines.dignities import DIGNITIES


[docs] class AspectPatternAnalyzer: """ Implements the Analyzer protocol to find major aspect patterns. The results are stored in chart.metadata['aspect_patterns'] """ def __init__(self, stellium_min: int = 3) -> None: """ Create a chart analyzer to find aspect patterns. Args: stellium_min: Minimum number of planets/angles to count as a stellium (3). """ self.stellium_min = stellium_min @property def analyzer_name(self) -> str: return "Aspect Patterns" @property def metadata_name(self) -> str: return "aspect_patterns"
[docs] def analyze(self, chart: CalculatedChart) -> list[AspectPattern]: """ Runs all pattern detectors and returns a list of findings serialized as dictionaries for metadata. """ # Get the lists we'll be working with planets = [ p for p in chart.positions if p.object_type in (ObjectType.PLANET, ObjectType.ANGLE) ] aspects = list(chart.aspects) found_patterns: list[AspectPattern] = [] # Find all pattern types found_patterns.extend(self._find_grand_trines(planets, aspects)) found_patterns.extend(self._find_t_squares(planets, aspects)) found_patterns.extend(self._find_yods(planets, aspects)) found_patterns.extend(self._find_grand_crosses(planets, aspects)) found_patterns.extend( self._find_stelliums(planets, min_planets=self.stellium_min) ) found_patterns.extend(self._find_mystic_rectangles(planets, aspects)) found_patterns.extend(self._find_kites(planets, aspects, found_patterns)) return found_patterns
def _get_aspect( self, aspect_list: list[Aspect], p1: CelestialPosition, p2: CelestialPosition ) -> Aspect | None: """Helper to find an aspect between two specific planets.""" for a in aspect_list: if (a.object1 == p1 and a.object2 == p2) or ( a.object1 == p2 and a.object2 == p1 ): return a return None def _get_element(self, sign: str) -> str | None: """Get the element for a sign from DIGNITIES.""" sign_data = DIGNITIES.get(sign) return sign_data.get("element") if sign_data else None def _get_modality(self, sign: str) -> str | None: """Get the modality (quality) for a sign from DIGNITIES.""" sign_data = DIGNITIES.get(sign) return sign_data.get("modality") if sign_data else None def _find_grand_trines( self, planets: list[CelestialPosition], aspects: list[Aspect] ) -> list[AspectPattern]: """Finds all Grand Trines (3 planets, 3 Trines).""" patterns = [] trines = [a for a in aspects if a.aspect_name == "Trine"] for p1, p2, p3 in combinations(planets, 3): a1 = self._get_aspect(trines, p1, p2) a2 = self._get_aspect(trines, p2, p3) a3 = self._get_aspect(trines, p3, p1) if a1 and a2 and a3: involved_planets = [p1, p2, p3] # Determine element using DIGNITIES elements = { self._get_element(p.sign) for p in involved_planets if self._get_element(p.sign) } element = elements.pop() if len(elements) == 1 else "Mixed" pattern = AspectPattern( name="Grand Trine", planets=involved_planets, aspects=[a1, a2, a3], element=element, quality=None, ) patterns.append(pattern) return patterns def _find_t_squares( self, planets: list[CelestialPosition], aspects: list[Aspect] ) -> list[AspectPattern]: """Finds all T-Squares (2 planets in Opposition, 1 Apex square to both).""" patterns = [] oppositions = [a for a in aspects if a.aspect_name == "Opposition"] squares = [a for a in aspects if a.aspect_name == "Square"] for opp in oppositions: p1, p2 = opp.object1, opp.object2 for apex in planets: if apex == p1 or apex == p2: continue s1 = self._get_aspect(squares, apex, p1) s2 = self._get_aspect(squares, apex, p2) if s1 and s2: involved_planets = [p1, p2, apex] # Determine quality/modality qualities = { self._get_modality(p.sign) for p in involved_planets if self._get_modality(p.sign) } quality = qualities.pop() if len(qualities) == 1 else "Mixed" pattern = AspectPattern( name="T-Square", planets=involved_planets, # p1/p2 are opposition, apex is focal aspects=[opp, s1, s2], element=None, quality=quality, ) patterns.append(pattern) return patterns def _find_yods( self, planets: list[CelestialPosition], aspects: list[Aspect] ) -> list[AspectPattern]: """ Finds all Yods (Finger of God): 2 planets in sextile, both quincunx to a third (apex) planet. """ patterns = [] sextiles = [a for a in aspects if a.aspect_name == "Sextile"] quincunxes = [a for a in aspects if a.aspect_name == "Quincunx"] for sextile in sextiles: p1, p2 = sextile.object1, sextile.object2 for apex in planets: if apex == p1 or apex == p2: continue q1 = self._get_aspect(quincunxes, apex, p1) q2 = self._get_aspect(quincunxes, apex, p2) if q1 and q2: pattern = AspectPattern( name="Yod", planets=[p1, p2, apex], # apex is the focal point aspects=[sextile, q1, q2], element=None, quality=None, ) patterns.append(pattern) return patterns def _find_kites( self, planets: list[CelestialPosition], aspects: list[Aspect], found_patterns: list[AspectPattern], ) -> list[AspectPattern]: """ Finds all Kites: A Grand Trine with a 4th planet opposite one point and sextile to the other two. """ patterns = [] grand_trines = [p for p in found_patterns if p.name == "Grand Trine"] oppositions = [a for a in aspects if a.aspect_name == "Opposition"] sextiles = [a for a in aspects if a.aspect_name == "Sextile"] for gt in grand_trines: gt_planets = gt.planets # Try each planet in the Grand Trine as the opposition point for i, opp_point in enumerate(gt_planets): other_planets = [p for j, p in enumerate(gt_planets) if j != i] # Look for a 4th planet opposite to opp_point for focal in planets: if focal in gt_planets: continue opp = self._get_aspect(oppositions, focal, opp_point) if not opp: continue # Check if focal is sextile to the other two GT planets s1 = self._get_aspect(sextiles, focal, other_planets[0]) s2 = self._get_aspect(sextiles, focal, other_planets[1]) if s1 and s2: # Get element from the Grand Trine element = gt.element pattern = AspectPattern( name="Kite", planets=gt_planets + [focal], aspects=gt.aspects + [opp, s1, s2], element=element, quality=None, ) patterns.append(pattern) return patterns def _find_grand_crosses( self, planets: list[CelestialPosition], aspects: list[Aspect] ) -> list[AspectPattern]: """ Finds all Grand Crosses: 4 planets forming 2 oppositions and 4 squares (all square each other). """ patterns = [] oppositions = [a for a in aspects if a.aspect_name == "Opposition"] squares = [a for a in aspects if a.aspect_name == "Square"] # Need at least 2 oppositions for a Grand Cross for opp1, opp2 in combinations(oppositions, 2): planets_set = {opp1.object1, opp1.object2, opp2.object1, opp2.object2} # Grand Cross needs exactly 4 planets if len(planets_set) != 4: continue planets_list = list(planets_set) # Check that all 4 planets square each other appropriately # There should be 4 squares total found_squares = [] for p1, p2 in combinations(planets_list, 2): sq = self._get_aspect(squares, p1, p2) if sq: found_squares.append(sq) if len(found_squares) == 4: # Determine quality/modality qualities = { self._get_modality(p.sign) for p in planets_list if self._get_modality(p.sign) } quality = qualities.pop() if len(qualities) == 1 else "Mixed" pattern = AspectPattern( name="Grand Cross", planets=planets_list, aspects=[opp1, opp2] + found_squares, element=None, quality=quality, ) patterns.append(pattern) return patterns def _find_mystic_rectangles( self, planets: list[CelestialPosition], aspects: list[Aspect] ) -> list[AspectPattern]: """ Finds all Mystic Rectangles: 2 oppositions connected by 4 trines and 2 sextiles, forming a rectangle. """ patterns = [] oppositions = [a for a in aspects if a.aspect_name == "Opposition"] trines = [a for a in aspects if a.aspect_name == "Trine"] sextiles = [a for a in aspects if a.aspect_name == "Sextile"] for opp1, opp2 in combinations(oppositions, 2): planets_set = {opp1.object1, opp1.object2, opp2.object1, opp2.object2} # Mystic Rectangle needs exactly 4 planets if len(planets_set) != 4: continue planets_list = list(planets_set) # Check for the required sextiles and trines found_sextiles = [] found_trines = [] for p1, p2 in combinations(planets_list, 2): sext = self._get_aspect(sextiles, p1, p2) if sext: found_sextiles.append(sext) trine = self._get_aspect(trines, p1, p2) if trine: found_trines.append(trine) # Mystic Rectangle should have 2 sextiles and 2 trines if len(found_sextiles) == 2 and len(found_trines) == 2: pattern = AspectPattern( name="Mystic Rectangle", planets=planets_list, aspects=[opp1, opp2] + found_sextiles + found_trines, element=None, quality=None, ) patterns.append(pattern) return patterns def _find_stelliums( self, planets: list[CelestialPosition], min_planets: int = 3 ) -> list[AspectPattern]: """ Finds all Stelliums: 3+ planets in the same sign. You can adjust min_planets for stricter definitions. """ patterns = [] # Group planets by sign sign_groups: dict[str, list[CelestialPosition]] = {} for planet in planets: if planet.sign not in sign_groups: sign_groups[planet.sign] = [] sign_groups[planet.sign].append(planet) # Find groups with min_planets or more for sign, group in sign_groups.items(): if len(group) >= min_planets: element = self._get_element(sign) quality = self._get_modality(sign) pattern = AspectPattern( name="Stellium", planets=group, aspects=[], # Stelliums are based on sign, not aspects element=element, quality=quality, ) patterns.append(pattern) return patterns