Source code for stellium.engines.dispositors

"""
Dispositor graph calculation engine.

Dispositors trace the "chain of command" in a chart - each planet is disposed
by the ruler of the sign it occupies. Following these chains reveals:

1. **Planetary Dispositors**: Which planets "dispose" (rule over) which others.
   The final dispositor is the planet that rules its own sign (e.g., Mars in Aries).

2. **House Dispositors** (Kate's innovation): Which life areas flow into which others.
   "What planet rules this house's cusp, and what house is THAT planet in?"
   The final dispositor house is the life area that supports/feeds the others.

Example:
    >>> from stellium import ChartBuilder
    >>> from stellium.engines.dispositors import DispositorEngine
    >>>
    >>> chart = ChartBuilder.from_notable("Albert Einstein").calculate()
    >>> engine = DispositorEngine(chart)
    >>>
    >>> # Planetary dispositors
    >>> planetary = engine.planetary()
    >>> print(f"Final dispositor: {planetary.final_dispositor}")
    >>> print(f"Mutual receptions: {planetary.mutual_receptions}")
    >>>
    >>> # House dispositors (Kate's innovation)
    >>> houses = engine.house_based()
    >>> print(f"Final dispositor house: {houses.final_dispositor}")
"""

from dataclasses import dataclass
from typing import Literal

import graphviz

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

# Traditional planets only (no outer planets - they can't rule signs traditionally)
TRADITIONAL_PLANETS = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]

# Sign order for reference
SIGNS = [
    "Aries",
    "Taurus",
    "Gemini",
    "Cancer",
    "Leo",
    "Virgo",
    "Libra",
    "Scorpio",
    "Sagittarius",
    "Capricorn",
    "Aquarius",
    "Pisces",
]


[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 DispositorEdge: """ A single edge in the dispositor graph. For planetary: "Sun in Leo is disposed by Sun" (self-disposing) For house: "House 10 (Capricorn) flows to House 11 (where Saturn is)" """ source: str # Planet name or house number as string target: str # Planet name or house number as string source_sign: str # The sign of the source ruler: str # The ruling planet def __str__(self) -> str: return f"{self.source}{self.target}"
[docs] @dataclass(frozen=True) class MutualReception: """ Two nodes that dispose each other. Planetary: Mars in Capricorn ↔ Saturn in Aries (each rules the other's sign) House: House 9 ↔ House 11 (their rulers are in each other's houses) """ node1: str node2: str planet1: str | None = None # For house-based: the ruling planet of node1 planet2: str | None = None # For house-based: the ruling planet of node2 def __str__(self) -> str: return f"{self.node1}{self.node2}"
[docs] @dataclass(frozen=True) class DispositorResult: """ Complete dispositor analysis result. Contains the full graph structure, final dispositor(s), mutual receptions, and chains for analysis. Attributes: mode: "planetary" or "house" edges: All edges in the dispositor graph final_dispositor: The node(s) where all chains terminate (or None if loops) mutual_receptions: List of mutual reception pairs chains: Dict mapping each starting node to its full chain rulership_system: "traditional" or "modern" """ mode: Literal["planetary", "house"] edges: tuple[DispositorEdge, ...] final_dispositor: str | tuple[str, ...] | None mutual_receptions: tuple[MutualReception, ...] chains: dict[str, list[str]] rulership_system: str def __str__(self) -> str: if self.final_dispositor: if isinstance(self.final_dispositor, tuple): fd = " & ".join(self.final_dispositor) else: fd = self.final_dispositor return f"Final dispositor: {fd}" elif self.mutual_receptions: mrs = ", ".join(str(mr) for mr in self.mutual_receptions) return f"Mutual receptions: {mrs} (no single final dispositor)" else: return "Complex loop structure (no final dispositor)"
[docs] def get_chain(self, start: str) -> list[str]: """Get the full dispositor chain starting from a node.""" return self.chains.get(start, [])
[docs] def to_dict(self) -> dict: """Serialize to dictionary for JSON export.""" return { "mode": self.mode, "edges": [ { "source": e.source, "target": e.target, "sign": e.source_sign, "ruler": e.ruler, } for e in self.edges ], "final_dispositor": self.final_dispositor, "mutual_receptions": [ {"node1": mr.node1, "node2": mr.node2} for mr in self.mutual_receptions ], "chains": self.chains, "rulership_system": self.rulership_system, }
# ============================================================================= # Engine # =============================================================================
[docs] class DispositorEngine: """ Calculate dispositor graphs for a chart. Supports two modes: - Planetary: Traditional planet-rules-planet dispositors - House: Kate's innovation - life-area-flows-to-life-area dispositors Example: >>> chart = ChartBuilder.from_notable("Albert Einstein").calculate() >>> engine = DispositorEngine(chart) >>> >>> # Planetary dispositors >>> p = engine.planetary() >>> print(p.final_dispositor) >>> >>> # House dispositors >>> h = engine.house_based() >>> print(h.final_dispositor) """ def __init__( self, chart: CalculatedChart, rulership_system: Literal["traditional", "modern"] = "traditional", house_system: str | None = None, ): """ Initialize the dispositor engine. Args: chart: The calculated chart to analyze rulership_system: "traditional" or "modern" rulerships house_system: House system to use (defaults to chart's default) """ self.chart = chart self.rulership_system = rulership_system self.house_system = house_system or chart.default_house_system
[docs] def planetary(self) -> DispositorResult: """ Calculate planetary dispositor graph. Each planet is disposed by the ruler of the sign it occupies. A planet in its own sign (e.g., Mars in Aries) is self-disposing. Returns: DispositorResult with planetary dispositor analysis """ edges = [] graph = {} # planet -> disposes_to_planet # Get traditional planets from the chart for planet_name in TRADITIONAL_PLANETS: planet = self.chart.get_object(planet_name) if planet is None: continue sign = planet.sign ruler = get_sign_ruler(sign, self.rulership_system) # Create edge edges.append( DispositorEdge( source=planet_name, target=ruler, source_sign=sign, ruler=ruler, ) ) graph[planet_name] = ruler # Find mutual receptions mutual_receptions = self._find_mutual_receptions(graph) # Build chains and find final dispositor chains = self._build_chains(graph) final_dispositor = self._find_final_dispositor(graph, chains) return DispositorResult( mode="planetary", edges=tuple(edges), final_dispositor=final_dispositor, mutual_receptions=tuple(mutual_receptions), chains=chains, rulership_system=self.rulership_system, )
[docs] def house_based(self) -> DispositorResult: """ Calculate house-based dispositor graph (Kate's innovation). For each house: find the ruler of the sign on its cusp, then find what house that ruling planet is in. This shows how life areas flow into and support each other. Returns: DispositorResult with house-based dispositor analysis """ edges = [] graph = {} # house_num_str -> target_house_num_str house_rulers = {} # house_num_str -> ruling planet name houses = self.chart.get_houses(self.house_system) for house_num in range(1, 13): house_str = str(house_num) sign = houses.get_sign(house_num) ruler = get_sign_ruler(sign, self.rulership_system) house_rulers[house_str] = ruler # Find what house the ruler is in ruler_house = self.chart.get_house(ruler, self.house_system) if ruler_house is None: # Ruler not in chart (outer planet in traditional mode?) # Fall back to finding the planet position ruler_pos = self.chart.get_object(ruler) if ruler_pos: # Calculate house manually from longitude ruler_house = self._longitude_to_house(ruler_pos.longitude, houses) if ruler_house is not None: target_str = str(ruler_house) edges.append( DispositorEdge( source=house_str, target=target_str, source_sign=sign, ruler=ruler, ) ) graph[house_str] = target_str # Find mutual receptions (with ruler info for house-based) mutual_receptions = self._find_house_mutual_receptions(graph, house_rulers) # Build chains and find final dispositor chains = self._build_chains(graph) final_dispositor = self._find_final_dispositor(graph, chains) return DispositorResult( mode="house", edges=tuple(edges), final_dispositor=final_dispositor, mutual_receptions=tuple(mutual_receptions), chains=chains, rulership_system=self.rulership_system, )
def _longitude_to_house(self, longitude: float, houses) -> int: """ Determine which house a longitude falls in. Args: longitude: The ecliptic longitude (0-360) houses: HouseCusps object Returns: House number (1-12) """ cusps = houses.cusps # List of 12 cusp longitudes for i in range(12): cusp_start = cusps[i] cusp_end = cusps[(i + 1) % 12] # Handle wrap-around at 360/0 degrees if cusp_start > cusp_end: # Cusp crosses 0 degrees if longitude >= cusp_start or longitude < cusp_end: return i + 1 else: if cusp_start <= longitude < cusp_end: return i + 1 return 1 # Fallback (shouldn't happen) def _find_mutual_receptions(self, graph: dict[str, str]) -> list[MutualReception]: """Find mutual receptions in a planetary graph.""" mutual = [] seen = set() for node1, target1 in graph.items(): if target1 in graph: target2 = graph[target1] if target2 == node1 and node1 != target1: # Mutual reception! pair = tuple(sorted([node1, target1])) if pair not in seen: seen.add(pair) mutual.append(MutualReception(node1=node1, node2=target1)) return mutual def _find_house_mutual_receptions( self, graph: dict[str, str], house_rulers: dict[str, str], ) -> list[MutualReception]: """Find mutual receptions in a house graph, including ruler info.""" mutual = [] seen = set() for node1, target1 in graph.items(): if target1 in graph: target2 = graph[target1] if target2 == node1 and node1 != target1: # Mutual reception! pair = tuple(sorted([node1, target1])) if pair not in seen: seen.add(pair) mutual.append( MutualReception( node1=node1, node2=target1, planet1=house_rulers.get(node1), planet2=house_rulers.get(target1), ) ) return mutual def _build_chains(self, graph: dict[str, str]) -> dict[str, list[str]]: """ Build the full dispositor chain for each starting node. Follows edges until reaching a self-loop or a cycle. """ chains = {} for start in graph: chain = [start] current = start visited = {start} while current in graph: next_node = graph[current] chain.append(next_node) if next_node == current: # Self-disposing (final dispositor) break if next_node in visited: # Cycle detected (mutual reception or longer loop) break visited.add(next_node) current = next_node chains[start] = chain return chains def _find_final_dispositor( self, graph: dict[str, str], chains: dict[str, list[str]], ) -> str | tuple[str, ...] | None: """ Find the final dispositor - the node where all chains terminate. A final dispositor is a node that disposes itself (planet in own sign, or house whose ruler is in that same house). If there are mutual receptions acting as the sink, returns both nodes in the mutual reception pair. Returns: - Single string if one self-disposing final dispositor - Tuple of strings if mutual reception acts as sink, or multiple self-disposing - None if no clear final dispositor (complex loops) """ # Find self-disposing nodes (TRUE final dispositors) self_disposing = [] for node, target in graph.items(): if target == node: self_disposing.append(node) if len(self_disposing) == 1: return self_disposing[0] elif len(self_disposing) > 1: return tuple(sorted(self_disposing)) # No self-disposing node - find mutual reception(s) acting as sink # A mutual reception is a sink if chains from other nodes flow into it mutual_pairs = [] for node1, target1 in graph.items(): if target1 in graph and graph[target1] == node1 and node1 != target1: pair = tuple(sorted([node1, target1])) if pair not in mutual_pairs: mutual_pairs.append(pair) if len(mutual_pairs) == 1: # Single mutual reception acts as sink return mutual_pairs[0] elif len(mutual_pairs) > 1: # Multiple mutual receptions - find which one is the main sink # by counting how many chains terminate at each pair pair_counts = dict.fromkeys(mutual_pairs, 0) for chain in chains.values(): if len(chain) >= 2: terminal = chain[-1] for pair in mutual_pairs: if terminal in pair: pair_counts[pair] += 1 break max_count = max(pair_counts.values()) top_pairs = [p for p, c in pair_counts.items() if c == max_count] if len(top_pairs) == 1: return top_pairs[0] # Multiple equal - return all as flat tuple all_nodes = set() for pair in top_pairs: all_nodes.update(pair) return tuple(sorted(all_nodes)) return None
# ============================================================================= # Graphviz Rendering # =============================================================================
[docs] def render_dispositor_graph( result: DispositorResult, *, use_glyphs: bool = True, title: str | None = None, ) -> "graphviz.Digraph": """ Render a single dispositor result as a graphviz Digraph. Args: result: DispositorResult from DispositorEngine use_glyphs: Use planet glyphs (☉♀♂) instead of names title: Optional title for the graph Returns: graphviz.Digraph object (call .render() to save) Raises: ImportError: If graphviz is not installed """ from stellium.core.registry import CELESTIAL_REGISTRY # Create digraph with Stellium styling dot = graphviz.Digraph(comment=title or f"{result.mode.title()} Dispositors") # Stellium palette (matching PDF/chart styling) dot.attr(bgcolor="#F5F0E6") # Cream background dot.attr( "node", shape="circle", style="filled", fillcolor="#E8E0D4", # Warm beige nodes color="#8B7355", # Warm brown border fontname="Crimson Pro", fontsize="14", penwidth="1.5", ) dot.attr( "edge", color="#9B8AA6", # Purple-ish edges penwidth="1.5", ) # Set title if title: dot.attr(label=title, fontsize="16", fontname="Crimson Pro", labelloc="t") # Helper to get node label def get_label(node: str) -> str: if result.mode == "planetary" and use_glyphs: if node in CELESTIAL_REGISTRY: return CELESTIAL_REGISTRY[node].glyph elif result.mode == "house": # For houses, just use the number return node return node # Collect all nodes nodes = set() for edge in result.edges: nodes.add(edge.source) nodes.add(edge.target) # Find final dispositor(s) for special styling final_set = set() if result.final_dispositor: if isinstance(result.final_dispositor, tuple): final_set = set(result.final_dispositor) else: final_set = {result.final_dispositor} # Find mutual reception nodes mr_nodes = set() for mr in result.mutual_receptions: mr_nodes.add(mr.node1) mr_nodes.add(mr.node2) # Add nodes with special styling for final dispositor for node in nodes: label = get_label(node) if node in final_set: # Final dispositor gets gold fill and thicker border dot.node( node, label, fillcolor="#D4AF37", # Gold color="#8B6914", # Darker gold border penwidth="2.5", ) elif node in mr_nodes: # Mutual reception nodes get a subtle purple tint dot.node( node, label, fillcolor="#D8D0E0", # Light purple ) else: dot.node(node, label) # Add edges seen_edges = set() for edge in result.edges: _edge_key = (edge.source, edge.target) # Check if this is part of a mutual reception is_mutual = False for mr in result.mutual_receptions: if (edge.source == mr.node1 and edge.target == mr.node2) or ( edge.source == mr.node2 and edge.target == mr.node1 ): is_mutual = True break if is_mutual: # Render mutual receptions with bidirectional arrow (only once) pair = tuple(sorted([edge.source, edge.target])) if pair not in seen_edges: seen_edges.add(pair) dot.edge( edge.source, edge.target, dir="both", color="#7B6B8A", # Darker purple for emphasis penwidth="2.0", ) else: # Self-loop (final dispositor) if edge.source == edge.target: dot.edge( edge.source, edge.target, color="#8B6914", # Gold edge for self-loop penwidth="2.0", ) else: dot.edge(edge.source, edge.target) return dot
[docs] def render_both_dispositors( planetary: DispositorResult, house: DispositorResult, *, use_glyphs: bool = True, ) -> "graphviz.Digraph": """ Render both planetary and house dispositors as subgraphs in a single SVG. Args: planetary: Planetary DispositorResult house: House-based DispositorResult use_glyphs: Use planet glyphs for planetary graph Returns: graphviz.Digraph with both graphs as labeled clusters Example: >>> engine = DispositorEngine(chart) >>> planetary = engine.planetary() >>> house = engine.house_based() >>> graph = render_both_dispositors(planetary, house) >>> graph.render("dispositors", format="svg") """ from stellium.core.registry import CELESTIAL_REGISTRY # Create parent digraph dot = graphviz.Digraph(comment="Dispositor Graphs") dot.attr(bgcolor="#F5F0E6", rankdir="TB") dot.attr( "node", shape="circle", style="filled", fillcolor="#E8E0D4", color="#8B7355", fontname="Crimson Pro", fontsize="14", penwidth="1.5", ) dot.attr( "edge", color="#9B8AA6", penwidth="1.5", ) def get_label(node: str, mode: str) -> str: if mode == "planetary" and use_glyphs: if node in CELESTIAL_REGISTRY: return CELESTIAL_REGISTRY[node].glyph return node def add_subgraph(result: DispositorResult, cluster_id: str, label: str): """Add a dispositor result as a subgraph cluster.""" with dot.subgraph(name=f"cluster_{cluster_id}") as c: c.attr(label=label, fontsize="14", fontname="Crimson Pro") c.attr(style="rounded", color="#8B7355", bgcolor="#FAF7F2") # Find special nodes final_set = set() if result.final_dispositor: if isinstance(result.final_dispositor, tuple): final_set = set(result.final_dispositor) else: final_set = {result.final_dispositor} mr_nodes = set() for mr in result.mutual_receptions: mr_nodes.add(mr.node1) mr_nodes.add(mr.node2) # Collect nodes nodes = set() for edge in result.edges: nodes.add(edge.source) nodes.add(edge.target) # Prefix node names to avoid conflicts between subgraphs prefix = f"{cluster_id}_" # Add nodes for node in nodes: node_id = prefix + node label_text = get_label(node, result.mode) if node in final_set: c.node( node_id, label_text, fillcolor="#D4AF37", color="#8B6914", penwidth="2.5", ) elif node in mr_nodes: c.node( node_id, label_text, fillcolor="#D8D0E0", ) else: c.node(node_id, label_text) # Add edges seen_edges = set() for edge in result.edges: src_id = prefix + edge.source tgt_id = prefix + edge.target is_mutual = False for mr in result.mutual_receptions: if (edge.source == mr.node1 and edge.target == mr.node2) or ( edge.source == mr.node2 and edge.target == mr.node1 ): is_mutual = True break if is_mutual: pair = tuple(sorted([edge.source, edge.target])) if pair not in seen_edges: seen_edges.add(pair) c.edge( src_id, tgt_id, dir="both", color="#7B6B8A", penwidth="2.0", ) elif edge.source == edge.target: c.edge( src_id, tgt_id, color="#8B6914", penwidth="2.0", ) else: c.edge(src_id, tgt_id) # Add both subgraphs add_subgraph(planetary, "planetary", "Planetary Dispositors") add_subgraph(house, "house", "House Dispositors") return dot