Source code for stellium.presentation.sections.core

"""
Core report sections for basic chart information.

Includes:
- ChartOverviewSection: Basic chart metadata (date, time, location)
- PlanetPositionSection: Positions of celestial objects
- HouseCuspsSection: House cusp positions for multiple systems
"""

import datetime as dt
from typing import Any

from stellium.core.comparison import Comparison
from stellium.core.models import CalculatedChart, ObjectType
from stellium.core.multichart import MultiChart
from stellium.utils.chart_ruler import get_chart_ruler_from_chart

from ._utils import (
    abbreviate_house_system,
    get_object_display,
    get_object_sort_key,
    get_sign_glyph,
)


[docs] class ChartOverviewSection: """ Overview section with basic chart information. Shows: - Native name (if available) - Birth date/time - Location - Chart type (day/night) - House system For Comparison objects, shows info for both charts. """ @property def section_name(self) -> str: return "Chart Overview"
[docs] def generate_data( self, chart: CalculatedChart | Comparison | MultiChart ) -> dict[str, Any]: """ Generate chart overview data. For Comparison/MultiChart objects, shows all charts' information. Why key-value format? - Simple label: value pairs - Easy to render as a list or small table - Human-readable structure """ # Handle MultiChart objects if isinstance(chart, MultiChart): return self._generate_multichart_data(chart) # Handle Comparison objects if isinstance(chart, Comparison): return self._generate_comparison_data(chart) return self._generate_single_chart_data(chart)
def _generate_single_chart_data( self, chart: CalculatedChart, label: str | None = None ) -> dict[str, Any]: """Generate overview data for a single chart.""" data = {} # Name (if available in metadata) if "name" in chart.metadata: name = chart.metadata["name"] if label: data[f"{label}"] = name else: data["Name"] = name # Date and time birth: dt.datetime = chart.datetime.local_datetime date_label = f"{label} Date" if label else "Date" time_label = f"{label} Time" if label else "Time" data[date_label] = birth.strftime("%B %d, %Y") data[time_label] = birth.strftime("%I:%M %p") if not label: # Only show timezone for single charts data["Timezone"] = str(chart.location.timezone) # Location loc = chart.location loc_label = f"{label} Location" if label else "Location" data[loc_label] = f"{loc.name}" if loc.name else "Unknown" if not label: # Only show detailed info for single charts data["Coordinates"] = f"{loc.latitude:.4f}°, {loc.longitude:.4f}°" # Chart metadata house_systems = ", ".join(chart.house_systems.keys()) data["House System"] = house_systems # Zodiac system if chart.zodiac_type: zodiac_display = chart.zodiac_type.value.title() if chart.zodiac_type.value == "sidereal" and chart.ayanamsa: ayanamsa_display = chart.ayanamsa.replace("_", " ").title() zodiac_display = f"{zodiac_display} ({ayanamsa_display})" data["Zodiac"] = zodiac_display if ( chart.zodiac_type.value == "sidereal" and chart.ayanamsa_value is not None ): degrees = int(chart.ayanamsa_value) minutes = int((chart.ayanamsa_value % 1) * 60) seconds = int(((chart.ayanamsa_value % 1) * 60 % 1) * 60) data["Ayanamsa"] = f"{degrees}°{minutes:02d}'{seconds:02d}\"" # Sect (if available in metadata) if "dignities" in chart.metadata: sect = chart.metadata["dignities"].get("sect", "unknown") data["Chart Sect"] = f"{sect.title()} Chart" # Chart Ruler try: ruler, asc_sign = get_chart_ruler_from_chart(chart) data["Chart Ruler"] = f"{ruler} ({asc_sign} Rising)" except (ValueError, KeyError): pass # Skip if ASC not found return { "type": "key_value", "data": data, } def _generate_comparison_data(self, comparison: Comparison) -> dict[str, Any]: """Generate overview data for a Comparison object.""" data = {} # Comparison type comp_type = comparison.comparison_type.value.title() data["Comparison Type"] = comp_type # Chart 1 info chart1 = comparison.chart1 label1 = comparison.chart1_label or "Chart 1" if "name" in chart1.metadata: data[label1] = chart1.metadata["name"] else: data[label1] = "(unnamed)" birth1: dt.datetime = chart1.datetime.local_datetime data[f"{label1} Date"] = birth1.strftime("%B %d, %Y") data[f"{label1} Time"] = birth1.strftime("%I:%M %p") data[f"{label1} Location"] = ( chart1.location.name if chart1.location.name else "Unknown" ) # Chart 2 info chart2 = comparison.chart2 label2 = comparison.chart2_label or "Chart 2" if "name" in chart2.metadata: data[label2] = chart2.metadata["name"] else: data[label2] = "(unnamed)" birth2: dt.datetime = chart2.datetime.local_datetime data[f"{label2} Date"] = birth2.strftime("%B %d, %Y") data[f"{label2} Time"] = birth2.strftime("%I:%M %p") data[f"{label2} Location"] = ( chart2.location.name if chart2.location.name else "Unknown" ) # Cross-chart aspect count data["Cross-Chart Aspects"] = len(comparison.cross_aspects) return { "type": "key_value", "data": data, } def _generate_multichart_data(self, multichart: MultiChart) -> dict[str, Any]: """Generate overview data for a MultiChart object.""" data = {} # Chart count and type chart_count = multichart.chart_count chart_types = {2: "Biwheel", 3: "Triwheel", 4: "Quadwheel"} data["Chart Type"] = chart_types.get(chart_count, f"{chart_count}-Wheel") # Relationship types (if any) if multichart.relationships: rel_types = {r.value.title() for r in multichart.relationships.values()} data["Relationship"] = ", ".join(rel_types) # Info for each chart for i, chart in enumerate(multichart.charts): label = ( multichart.labels[i] if i < len(multichart.labels) else f"Chart {i + 1}" ) if "name" in chart.metadata: data[label] = chart.metadata["name"] else: data[label] = "(unnamed)" birth: dt.datetime = chart.datetime.local_datetime data[f"{label} Date"] = birth.strftime("%B %d, %Y") data[f"{label} Time"] = birth.strftime("%I:%M %p") data[f"{label} Location"] = ( chart.location.name if chart.location.name else "Unknown" ) # Cross-chart aspect count total_aspects = sum( len(aspects) for aspects in multichart.cross_aspects.values() ) if total_aspects > 0: data["Cross-Chart Aspects"] = total_aspects return { "type": "key_value", "data": data, }
[docs] class PlanetPositionSection: """Table of planet positions. Shows: - Planet name - Sign + degree - House (optional) - Speed (optional, shows retrograde status) """ def __init__( self, include_speed: bool = False, include_house: bool = True, house_systems: str | list[str] = "all", ) -> None: """ Initialize section with display options. Args: include_speed: Show speed column (for retrograde detection) include_house: Show house placement column house_systems: Which systems to display: - "all": Show all calculated house systems (DEFAULT) - list[str]: Show specific systems (e.g., ["Placidus", "Whole Sign"]) - None: Show default system only """ self.include_speed = include_speed self.include_house = include_house # Normalize to internal representation if house_systems == "all": self._house_systems_mode = "all" self._house_systems = None elif isinstance(house_systems, list): self._house_systems_mode = "specific" self._house_systems = house_systems elif house_systems is None: self._house_systems_mode = "default" self._house_systems = None else: # Single system name as string self._house_systems_mode = "specific" self._house_systems = [house_systems] @property def section_name(self) -> str: return "Planet Positions"
[docs] def generate_data( self, chart: CalculatedChart | Comparison | MultiChart ) -> dict[str, Any]: """ Generate planet positions table. For Comparison/MultiChart objects, generates side-by-side tables for each chart. """ # Handle MultiChart objects with side-by-side tables if isinstance(chart, MultiChart): return self._generate_multichart_data(chart) # Handle Comparison objects with side-by-side tables if isinstance(chart, Comparison): return self._generate_comparison_data(chart) # Single chart: standard table return self._generate_single_chart_data(chart)
def _generate_single_chart_data( self, chart: CalculatedChart, title: str | None = None ) -> dict[str, Any]: """Generate position table data for a single chart.""" # Determine which house systems to show if self._house_systems_mode == "all": systems_to_show = list(chart.house_systems.keys()) elif self._house_systems_mode == "specific": systems_to_show = [ s for s in self._house_systems if s in chart.house_systems ] else: # "default" systems_to_show = [chart.default_house_system] # Build headers based on options headers = ["Planet", "Position"] if self.include_house and systems_to_show: for system_name in systems_to_show: abbrev = abbreviate_house_system(system_name) headers.append(f"House ({abbrev})") if self.include_speed: headers.append("Speed") headers.append("Motion") # Filter to planets, asteroids, nodes and points positions = [ p for p in chart.positions if p.object_type in ( ObjectType.PLANET, ObjectType.ASTEROID, ObjectType.NODE, ObjectType.POINT, ) ] # Sort positions consistently positions = sorted(positions, key=get_object_sort_key) # Build rows rows = [] for pos in positions: row = [] # Planet name with glyph display_name, glyph = get_object_display(pos.name) if glyph: row.append(f"{glyph} {display_name}") else: row.append(display_name) # Position with sign glyph degree = int(pos.sign_degree) minute = int((pos.sign_degree % 1) * 60) sign_glyph = get_sign_glyph(pos.sign) if sign_glyph: row.append(f"{sign_glyph} {pos.sign} {degree}°{minute:02d}'") else: row.append(f"{pos.sign} {degree}°{minute:02d}'") # House columns (one per system) if self.include_house and systems_to_show: for system_name in systems_to_show: try: house_placements = chart.house_placements[system_name] house = house_placements.get(pos.name, "—") row.append(str(house) if house else "—") except KeyError: row.append("—") # Speed and motion (if requested) if self.include_speed: row.append(f"{pos.speed_longitude:.4f}°/day") row.append("Retrograde" if pos.is_retrograde else "Direct") rows.append(row) result = {"type": "table", "headers": headers, "rows": rows} if title: result["title"] = title return result def _generate_comparison_data(self, comparison: Comparison) -> dict[str, Any]: """Generate side-by-side position tables for a Comparison.""" # Generate table data for each chart chart1_data = self._generate_single_chart_data( comparison.chart1, title=comparison.chart1_label ) chart2_data = self._generate_single_chart_data( comparison.chart2, title=comparison.chart2_label ) return { "type": "side_by_side_tables", "tables": [ { "title": chart1_data.get("title", "Chart 1"), "headers": chart1_data["headers"], "rows": chart1_data["rows"], }, { "title": chart2_data.get("title", "Chart 2"), "headers": chart2_data["headers"], "rows": chart2_data["rows"], }, ], } def _generate_multichart_data(self, multichart: MultiChart) -> dict[str, Any]: """Generate side-by-side position tables for a MultiChart.""" from stellium.core.chart_utils import get_chart_labels labels = get_chart_labels(multichart) tables = [] for i, (chart, label) in enumerate( zip(multichart.charts, labels, strict=False) ): chart_data = self._generate_single_chart_data(chart, title=label) tables.append( { "title": chart_data.get("title", f"Chart {i + 1}"), "headers": chart_data["headers"], "rows": chart_data["rows"], } ) return { "type": "side_by_side_tables", "tables": tables, }
[docs] class HouseCuspsSection: """ Table of house cusp positions for multiple house systems. Shows: - House number (1-12) - Cusp position for each calculated house system """ def __init__(self, systems: str | list[str] = "all") -> None: """ Initialize section with system selection. Args: systems: Which systems to display: - "all": Show all calculated house systems (DEFAULT) - list[str]: Show specific systems (e.g., ["Placidus", "Whole Sign"]) """ # Normalize to internal representation if systems == "all": self._systems_mode = "all" self._systems = None elif isinstance(systems, list): self._systems_mode = "specific" self._systems = systems else: # Single system name as string self._systems_mode = "specific" self._systems = [systems] @property def section_name(self) -> str: return "House Cusps"
[docs] def generate_data( self, chart: CalculatedChart | Comparison | MultiChart ) -> dict[str, Any]: """ Generate house cusps table. For Comparison/MultiChart objects, generates side-by-side tables for each chart. """ # Handle MultiChart objects with side-by-side tables if isinstance(chart, MultiChart): return self._generate_multichart_data(chart) # Handle Comparison objects with side-by-side tables if isinstance(chart, Comparison): return self._generate_comparison_data(chart) # Single chart: standard table return self._generate_single_chart_data(chart)
def _generate_single_chart_data( self, chart: CalculatedChart, title: str | None = None ) -> dict[str, Any]: """Generate house cusps table data for a single chart.""" from stellium.core.models import longitude_to_sign_and_degree # Determine which house systems to show if self._systems_mode == "all": systems_to_show = list(chart.house_systems.keys()) else: # "specific" systems_to_show = [s for s in self._systems if s in chart.house_systems] # Build headers headers = ["House"] for system_name in systems_to_show: abbrev = abbreviate_house_system(system_name) headers.append(f"Cusp ({abbrev})") # Build rows (houses 1-12) rows = [] for house_num in range(1, 13): row = [str(house_num)] for system_name in systems_to_show: house_cusps = chart.house_systems[system_name] cusp_longitude = house_cusps.cusps[house_num - 1] # Convert to sign and degree sign, sign_degree = longitude_to_sign_and_degree(cusp_longitude) degree = int(sign_degree) minute = int((sign_degree % 1) * 60) # Format with sign glyph sign_glyph = get_sign_glyph(sign) if sign_glyph: row.append(f"{degree}° {sign_glyph} {minute:02d}'") else: row.append(f"{degree}° {sign} {minute:02d}'") rows.append(row) result = {"type": "table", "headers": headers, "rows": rows} if title: result["title"] = title return result def _generate_comparison_data(self, comparison: Comparison) -> dict[str, Any]: """Generate side-by-side house cusps tables for a Comparison.""" chart1_data = self._generate_single_chart_data( comparison.chart1, title=comparison.chart1_label ) chart2_data = self._generate_single_chart_data( comparison.chart2, title=comparison.chart2_label ) return { "type": "side_by_side_tables", "tables": [ { "title": chart1_data.get("title", "Chart 1"), "headers": chart1_data["headers"], "rows": chart1_data["rows"], }, { "title": chart2_data.get("title", "Chart 2"), "headers": chart2_data["headers"], "rows": chart2_data["rows"], }, ], } def _generate_multichart_data(self, multichart: MultiChart) -> dict[str, Any]: """Generate side-by-side house cusps tables for a MultiChart.""" from stellium.core.chart_utils import get_chart_labels labels = get_chart_labels(multichart) tables = [] for i, (chart, label) in enumerate( zip(multichart.charts, labels, strict=False) ): chart_data = self._generate_single_chart_data(chart, title=label) tables.append( { "title": chart_data.get("title", f"Chart {i + 1}"), "headers": chart_data["headers"], "rows": chart_data["rows"], } ) return { "type": "side_by_side_tables", "tables": tables, }