Source code for stellium.presentation.sections.midpoint_tree

"""
Midpoint Tree visualization section.

Generates tree diagrams showing which midpoints aspect focal points (planets/angles).
This is a standard Uranian/Hamburg astrology technique for interpreting planetary pictures.

Example tree output:
    ☉ Sun (15°23' ♑)
    ├── ☽/♂ ☌ 0.3°   Moon/Mars conjunction
    ├── ♀/♄ □ 1.1°   Venus/Saturn square
    └── ☿/♃ ⊼ 0.8°   Mercury/Jupiter semi-square
"""

from dataclasses import dataclass
from typing import Any

import svgwrite

from stellium.core.models import (
    CalculatedChart,
    CelestialPosition,
    MidpointPosition,
    ObjectType,
)

from ._utils import get_aspect_display, get_object_display, get_sign_glyph


[docs] @dataclass class MidpointBranch: """A single branch in a midpoint tree (one midpoint aspecting the focal point).""" midpoint: MidpointPosition | CelestialPosition midpoint_display: str # e.g., "☽/♂" or "Moon/Mars" aspect_name: str # e.g., "Conjunction" aspect_glyph: str # e.g., "☌" orb: float midpoint_position: str # e.g., "15°05' ♑"
[docs] @dataclass class MidpointTree: """A complete midpoint tree for one focal point.""" focal_point: CelestialPosition focal_display: str # e.g., "☉ Sun" focal_position: str # e.g., "15°23' ♑" branches: list[MidpointBranch]
[docs] class MidpointTreeSection: """ Midpoint Tree visualization section. Generates tree diagrams showing which midpoints aspect focal points. Standard technique in Uranian/Hamburg astrology for interpreting planetary pictures. For each focal point (default: Sun, Moon, MC, ASC), shows all midpoints that aspect it within the configured orb. Example:: section = MidpointTreeSection( tree_bases=["Sun", "Moon", "MC", "ASC"], orb=1.5, aspect_mode="hard", # conjunction + 45° series output="both" ) """ # Default focal points (tree bases) DEFAULT_TREE_BASES = ["Sun", "Moon", "MC", "ASC"] # Default objects to include in midpoint calculations DEFAULT_BRANCH_OBJECTS = [ "Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", "ASC", "MC", "True Node", ] # Hard aspects for Uranian work (the 22.5° series, but we use 45° increments) HARD_ASPECTS = { "Conjunction": 0, "Semisquare": 45, "Square": 90, "Sesquisquare": 135, "Opposition": 180, } ALL_ASPECTS = { "Conjunction": 0, "Sextile": 60, "Square": 90, "Trine": 120, "Opposition": 180, } def __init__( self, tree_bases: list[str] | None = None, branch_objects: list[str] | None = None, orb: float = 1.5, aspect_mode: str = "conjunction", output: str = "both", ) -> None: """ Initialize midpoint tree section. Args: tree_bases: Focal points to build trees for. Default: ["Sun", "Moon", "MC", "ASC"] branch_objects: Objects to include in midpoint pairs. Default: 10 planets + ASC + MC + True Node orb: Maximum orb in degrees (default 1.5°) aspect_mode: Which aspects to check: - "conjunction": Only conjunctions (0°) - "hard": Conjunction + 45° series (0°, 45°, 90°, 135°, 180°) - "all": All major aspects output: What to generate: - "svg": Just SVG visualization - "text": Just text output - "both": Both SVG and text (default) """ self.tree_bases = tree_bases or self.DEFAULT_TREE_BASES self.branch_objects = branch_objects or self.DEFAULT_BRANCH_OBJECTS self.orb = orb self.aspect_mode = aspect_mode self.output = output # Set which aspects to check based on mode if aspect_mode == "conjunction": self._aspects = {"Conjunction": 0} elif aspect_mode == "hard": self._aspects = self.HARD_ASPECTS.copy() else: # all self._aspects = self.ALL_ASPECTS.copy() @property def section_name(self) -> str: if self.aspect_mode == "hard": return "Midpoint Trees (Hard Aspects)" elif self.aspect_mode == "conjunction": return "Midpoint Trees (Conjunctions)" return "Midpoint Trees"
[docs] def generate_data(self, chart: CalculatedChart) -> dict[str, Any]: """Generate midpoint tree visualization data.""" # Get midpoints from chart midpoints = [p for p in chart.positions if p.object_type == ObjectType.MIDPOINT] if not midpoints: return { "type": "text", "text": ( "No midpoints calculated. Add MidpointCalculator() to include them:\n\n" " from stellium.components import MidpointCalculator\n\n" " chart = (\n" " ChartBuilder.from_native(native)\n" " .add_component(MidpointCalculator())\n" " .calculate()\n" " )" ), } # Filter midpoints to only those made from branch_objects filtered_midpoints = self._filter_midpoints(midpoints) if not filtered_midpoints: return { "type": "text", "text": "No midpoints found matching the configured branch objects.", } # Build trees for each focal point trees = [] for base_name in self.tree_bases: focal = self._get_position(chart, base_name) if focal is None: continue tree = self._build_tree(focal, filtered_midpoints) if tree.branches: # Only include trees that have branches trees.append(tree) if not trees: return { "type": "text", "text": f"No midpoints found within {self.orb}° of focal points.", } # Generate output based on mode results = [] if self.output in ("text", "both"): text_output = self._render_text_trees(trees) results.append( ( "Midpoint Trees", { "type": "text", "text": text_output, }, ) ) if self.output in ("svg", "both"): svg_output = self._render_svg_trees(trees) # Calculate height based on content height = self._calculate_svg_height(trees) results.append( ( "Midpoint Tree Visualization", { "type": "svg", "content": svg_output, "width": 780, "height": height, }, ) ) if len(results) == 1: return results[0][1] else: return { "type": "compound", "sections": results, }
def _filter_midpoints( self, midpoints: list[CelestialPosition] ) -> list[CelestialPosition]: """Filter midpoints to only those made from branch_objects.""" filtered = [] branch_set = set(self.branch_objects) for mp in midpoints: if isinstance(mp, MidpointPosition): # Direct access to component objects if mp.object1.name in branch_set and mp.object2.name in branch_set: filtered.append(mp) else: # Parse name for legacy midpoints obj1, obj2 = self._parse_midpoint_name(mp.name) if obj1 in branch_set and obj2 in branch_set: filtered.append(mp) return filtered def _get_position( self, chart: CalculatedChart, name: str ) -> CelestialPosition | None: """Get a position from the chart by name.""" for pos in chart.positions: if pos.name == name: return pos return None def _build_tree( self, focal: CelestialPosition, midpoints: list[CelestialPosition] ) -> MidpointTree: """Build a midpoint tree for a focal point.""" branches = [] for mp in midpoints: # Skip if focal point is part of this midpoint if isinstance(mp, MidpointPosition): if focal.name in (mp.object1.name, mp.object2.name): continue else: obj1, obj2 = self._parse_midpoint_name(mp.name) if focal.name in (obj1, obj2): continue # Check each aspect type for aspect_name, aspect_angle in self._aspects.items(): orb = self._calculate_orb(focal.longitude, mp.longitude, aspect_angle) if orb <= self.orb: # Get display info mp_display = self._get_midpoint_glyph_display(mp) aspect_display_name, aspect_glyph = get_aspect_display(aspect_name) # Format midpoint position mp_pos = self._format_position(mp) branches.append( MidpointBranch( midpoint=mp, midpoint_display=mp_display, aspect_name=aspect_display_name, aspect_glyph=aspect_glyph, orb=orb, midpoint_position=mp_pos, ) ) # Sort branches by orb (tightest first) branches.sort(key=lambda b: b.orb) # Get focal point display info focal_display_name, focal_glyph = get_object_display(focal.name) focal_display = ( f"{focal_glyph} {focal_display_name}" if focal_glyph else focal_display_name ) focal_pos = self._format_position(focal) return MidpointTree( focal_point=focal, focal_display=focal_display, focal_position=focal_pos, branches=branches, ) def _calculate_orb(self, lon1: float, lon2: float, aspect_angle: float) -> float: """Calculate orb between two longitudes for a given aspect.""" diff = abs(lon1 - lon2) if diff > 180: diff = 360 - diff return abs(diff - aspect_angle) def _parse_midpoint_name(self, name: str) -> tuple[str, str]: """Parse midpoint name like 'Midpoint:Sun/Moon' into components.""" if ":" in name: pair_part = name.split(":")[1] else: pair_part = name pair_part = pair_part.replace(" (indirect)", "") parts = pair_part.split("/") if len(parts) == 2: return parts[0], parts[1] return "", "" def _get_midpoint_glyph_display(self, mp: CelestialPosition) -> str: """Get glyph-based display for a midpoint (e.g., '☽/♂').""" if isinstance(mp, MidpointPosition): name1, glyph1 = get_object_display(mp.object1.name) name2, glyph2 = get_object_display(mp.object2.name) else: obj1, obj2 = self._parse_midpoint_name(mp.name) name1, glyph1 = get_object_display(obj1) name2, glyph2 = get_object_display(obj2) # Use glyph if available, otherwise fall back to short name for each component display1 = glyph1 if glyph1 else name1[:2] display2 = glyph2 if glyph2 else name2[:2] return f"{display1}/{display2}" def _get_midpoint_name_display(self, mp: CelestialPosition) -> str: """Get name-based display for a midpoint (e.g., 'Moon/Mars').""" if isinstance(mp, MidpointPosition): return f"{mp.object1.name}/{mp.object2.name}" else: obj1, obj2 = self._parse_midpoint_name(mp.name) return f"{obj1}/{obj2}" def _format_position(self, pos: CelestialPosition) -> str: """Format a position as '15°23' ♑'.""" degree = int(pos.sign_degree) minute = int((pos.sign_degree % 1) * 60) sign_glyph = get_sign_glyph(pos.sign) return f"{degree}°{minute:02d}' {sign_glyph}" # ========================================================================= # Text Rendering # ========================================================================= def _render_text_trees(self, trees: list[MidpointTree]) -> str: """Render trees as text/ASCII art.""" lines = [] for i, tree in enumerate(trees): if i > 0: lines.append("") # Blank line between trees # Tree header lines.append(f"{tree.focal_display} ({tree.focal_position})") # Branches for j, branch in enumerate(tree.branches): is_last = j == len(tree.branches) - 1 prefix = "└──" if is_last else "├──" # Format: ├── ☽/♂ ☌ 0.3° Moon/Mars name_display = self._get_midpoint_name_display(branch.midpoint) line = f" {prefix} {branch.midpoint_display} {branch.aspect_glyph} {branch.orb:.1f}° {name_display}" lines.append(line) return "\n".join(lines) # ========================================================================= # SVG Rendering # ========================================================================= def _calculate_tree_height(self, tree: MidpointTree) -> int: """Calculate height needed for a single tree.""" # Tree header (40) + branches (25 each) + spacing (20) return 40 + len(tree.branches) * 25 + 20 def _calculate_svg_height(self, trees: list[MidpointTree], columns: int = 3) -> int: """Calculate required SVG height based on content with multi-column layout.""" if not trees: return 200 # Distribute trees into columns num_cols = min(columns, len(trees)) col_trees: list[list[MidpointTree]] = [[] for _ in range(num_cols)] for i, tree in enumerate(trees): col_trees[i % num_cols].append(tree) # Calculate height of each column col_heights = [] for col in col_trees: height = sum(self._calculate_tree_height(t) for t in col) col_heights.append(height) # Total height = header + tallest column return 60 + max(col_heights) if col_heights else 200 def _render_svg_trees(self, trees: list[MidpointTree]) -> str: """Render trees as SVG with multi-column layout.""" columns = 3 width = 780 # Slightly wider for 3 columns col_width = (width - 40) // columns # 40px total margin height = self._calculate_svg_height(trees, columns) dwg = svgwrite.Drawing(size=(width, height)) # Background dwg.add(dwg.rect((0, 0), (width, height), fill="#ffffff")) # Title dwg.add( dwg.text( "MIDPOINT TREES", insert=(width / 2, 30), text_anchor="middle", font_family="Arial, sans-serif", font_size="16px", font_weight="bold", fill="#2d2330", ) ) # Distribute trees into columns (round-robin for balance) num_cols = min(columns, len(trees)) col_trees: list[list[MidpointTree]] = [[] for _ in range(num_cols)] for i, tree in enumerate(trees): col_trees[i % num_cols].append(tree) # Render each column for col_idx, col in enumerate(col_trees): x_offset = 20 + col_idx * col_width y_offset = 60 for tree in col: y_offset = self._render_svg_tree( dwg, tree, y_offset, x_offset, col_width - 20 ) y_offset += 20 # Spacing between trees return dwg.tostring() def _render_svg_tree( self, dwg: svgwrite.Drawing, tree: MidpointTree, y_start: float, x_offset: float = 40, max_width: float = 200, ) -> float: """Render a single tree in SVG, return new y position.""" x_base = x_offset y = y_start # Tree header (focal point) dwg.add( dwg.text( f"{tree.focal_display}", insert=(x_base, y), font_family="Noto Sans Symbols2, Arial, sans-serif", font_size="14px", font_weight="bold", fill="#4a3353", ) ) # Position after the name dwg.add( dwg.text( f"({tree.focal_position})", insert=(x_base + 100, y), font_family="Noto Sans Symbols2, Arial, sans-serif", font_size="12px", fill="#6b4d6e", ) ) y += 25 # Draw branches for i, branch in enumerate(tree.branches): is_last = i == len(tree.branches) - 1 # Tree line line_x = x_base + 10 # Vertical line - always draw from above down to horizontal junction # For non-last branches, extend below for the next branch vertical_end = y - 5 if is_last else y + 10 dwg.add( dwg.line( (line_x, y - 15), (line_x, vertical_end), stroke="#d0c8c0", stroke_width=1, ) ) # Horizontal branch dwg.add( dwg.line( (line_x, y - 5), (line_x + 15, y - 5), stroke="#d0c8c0", stroke_width=1, ) ) # Branch content text_x = x_base + 30 # Midpoint glyphs dwg.add( dwg.text( branch.midpoint_display, insert=(text_x, y), font_family="Noto Sans Symbols2, Arial, sans-serif", font_size="12px", fill="#2d2330", ) ) # Aspect glyph dwg.add( dwg.text( branch.aspect_glyph, insert=(text_x + 45, y), font_family="Noto Sans Symbols2, Arial, sans-serif", font_size="12px", fill="#4a3353", ) ) # Orb dwg.add( dwg.text( f"{branch.orb:.1f}°", insert=(text_x + 65, y), font_family="Arial, sans-serif", font_size="11px", fill="#6b4d6e", ) ) # Full name name_display = self._get_midpoint_name_display(branch.midpoint) dwg.add( dwg.text( name_display, insert=(text_x + 110, y), font_family="Arial, sans-serif", font_size="11px", fill="#8b7b8e", ) ) y += 25 return y