"""
Dignity-related report sections.
Includes:
- DignitySection: Essential dignities table
- DispositorSection: Dispositor chains and final dispositors
"""
from typing import Any
from stellium.core.comparison import Comparison
from stellium.core.models import CalculatedChart, ObjectType
from stellium.core.multichart import MultiChart
from ._utils import get_object_display, get_object_sort_key
[docs]
class DignitySection:
"""
Table of essential dignities for planets.
Shows dignity scores and details for traditional and/or modern systems.
Gracefully handles missing dignity data with helpful message.
"""
def __init__(
self,
essential: str = "both",
show_details: bool = False,
) -> None:
"""
Initialize dignity section.
Args:
essential: Which essential dignity system(s) to show:
- "traditional": Traditional dignities only
- "modern": Modern dignities only
- "both": Both systems (DEFAULT)
- "none": Skip essential dignities
show_details: Show dignity names instead of just scores
"""
if essential not in ("traditional", "modern", "both", "none"):
raise ValueError(
f"essential must be 'traditional', 'modern', 'both', or 'none': got {essential}"
)
self.essential = essential
self.show_details = show_details
@property
def section_name(self) -> str:
return "Essential Dignities"
[docs]
def generate_data(
self, chart: CalculatedChart | Comparison | MultiChart
) -> dict[str, Any]:
"""
Generate dignity table.
For MultiChart/Comparison, shows dignities for each chart grouped by label.
"""
from stellium.core.chart_utils import get_all_charts, get_chart_labels
# Handle MultiChart/Comparison - show each chart's dignities
charts = get_all_charts(chart)
if len(charts) > 1:
labels = get_chart_labels(chart)
sections = []
for c, label in zip(charts, labels, strict=False):
single_data = self._generate_single_chart_data(c)
sections.append((f"{label} Dignities", single_data))
return {"type": "compound", "sections": sections}
# Single chart: standard processing
return self._generate_single_chart_data(chart)
def _generate_single_chart_data(self, chart: CalculatedChart) -> dict[str, Any]:
"""Generate dignity table for a single chart."""
# Check if dignity data exists
if "dignities" not in chart.metadata:
# Graceful handling: return helpful message
return {
"type": "text",
"content": (
"Add DignityComponent() to chart builder to enable dignity calculations.\n\n"
"Example:\n"
" chart = ChartBuilder.from_native(native).add_component(DignityComponent()).calculate()"
),
}
dignity_data = chart.metadata["dignities"]
planet_dignities = dignity_data.get("planet_dignities", {})
if not planet_dignities:
return {
"type": "text",
"content": "No dignity data available.",
}
# Build headers
headers = ["Planet"]
if self.essential in ("traditional", "both"):
if self.show_details:
headers.append("Traditional Dignities")
else:
headers.append("Trad Score")
if self.essential in ("modern", "both"):
if self.show_details:
headers.append("Modern Dignities")
else:
headers.append("Mod Score")
# Filter to planets only
positions = [
p
for p in chart.positions
if p.object_type
in (
ObjectType.PLANET,
ObjectType.ASTEROID,
)
]
# Sort positions consistently
positions = sorted(positions, key=get_object_sort_key)
# Build rows
rows = []
for pos in positions:
if pos.name not in planet_dignities:
continue
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)
dignity_info = planet_dignities[pos.name]
# Traditional column
if self.essential in ("traditional", "both"):
if "traditional" in dignity_info:
trad = dignity_info["traditional"]
if self.show_details:
# Show dignity names
dignity_names = trad.get("dignities", [])
if dignity_names:
row.append(", ".join(dignity_names))
else:
row.append("Peregrine" if trad.get("is_peregrine") else "—")
else:
# Show score
score = trad.get("score", 0)
row.append(f"{score:+d}" if score != 0 else "0")
else:
row.append("—")
# Modern column
if self.essential in ("modern", "both"):
if "modern" in dignity_info:
mod = dignity_info["modern"]
if self.show_details:
# Show dignity names
dignity_names = mod.get("dignities", [])
if dignity_names:
row.append(", ".join(dignity_names))
else:
row.append("—")
else:
# Show score
score = mod.get("score", 0)
row.append(f"{score:+d}" if score != 0 else "0")
else:
row.append("—")
rows.append(row)
return {"type": "table", "headers": headers, "rows": rows}
[docs]
class DispositorSection:
"""
Dispositor analysis section.
Shows planetary and/or house-based dispositor chains, final dispositor(s),
and mutual receptions. Text summary only - graphviz rendering is separate.
Example:
>>> section = DispositorSection(mode="both")
>>> data = section.generate_data(chart)
"""
def __init__(
self,
mode: str = "both",
rulership: str = "traditional",
house_system: str | None = None,
show_chains: bool = True,
) -> None:
"""
Initialize dispositor section.
Args:
mode: Which dispositor analysis to show:
- "planetary": Traditional planet-disposes-planet
- "house": Kate's house-based innovation
- "both": Show both (DEFAULT)
rulership: "traditional" or "modern" rulership system
house_system: House system for house-based mode (defaults to chart's default)
show_chains: Whether to show full chain details
"""
self.mode = mode
self.rulership = rulership
self.house_system = house_system
self.show_chains = show_chains
@property
def section_name(self) -> str:
if self.mode == "planetary":
return "Planetary Dispositors"
elif self.mode == "house":
return "House Dispositors"
return "Dispositors"
[docs]
def generate_data(
self, chart: CalculatedChart | Comparison | MultiChart
) -> dict[str, Any]:
"""
Generate dispositor analysis.
For MultiChart/Comparison, shows dispositors for each chart grouped by label.
Returns a compound section with subsections for planetary and/or house
dispositors, each showing final dispositor and mutual receptions.
"""
from stellium.core.chart_utils import get_all_charts, get_chart_labels
# Handle MultiChart/Comparison - show each chart's dispositors
charts = get_all_charts(chart)
if len(charts) > 1:
labels = get_chart_labels(chart)
all_sections = []
for c, label in zip(charts, labels, strict=False):
single_data = self._generate_single_chart_data(c)
# Unwrap compound sections and prefix with chart label
if single_data.get("type") == "compound":
for title, data in single_data["sections"]:
all_sections.append((f"{label} - {title}", data))
else:
all_sections.append((f"{label} Dispositors", single_data))
return {"type": "compound", "sections": all_sections}
# Single chart: standard processing
return self._generate_single_chart_data(chart)
def _generate_single_chart_data(self, chart: CalculatedChart) -> dict[str, Any]:
"""Generate dispositor analysis for a single chart."""
from stellium.engines.dispositors import DispositorEngine
engine = DispositorEngine(
chart,
rulership_system=self.rulership,
house_system=self.house_system,
)
sections = []
# Planetary dispositors
if self.mode in ("planetary", "both"):
planetary = engine.planetary()
sections.append(self._format_result(planetary, "Planetary"))
# House dispositors
if self.mode in ("house", "both"):
house = engine.house_based()
sections.append(self._format_result(house, "House-Based"))
# If only one mode, return that directly (as text with section content)
if len(sections) == 1:
title, data = sections[0]
return {
"type": "text",
"text": data.get("content", ""),
}
# Otherwise return compound section (list of tuples)
return {
"type": "compound",
"sections": sections,
}
def _format_result(self, result, title: str) -> dict[str, Any]:
"""Format a DispositorResult for display."""
from stellium.core.registry import CELESTIAL_REGISTRY
lines = []
# Final dispositor
if result.final_dispositor:
if isinstance(result.final_dispositor, tuple):
if result.mode == "planetary":
# Format with glyphs
fd_parts = []
for planet in result.final_dispositor:
if planet in CELESTIAL_REGISTRY:
glyph = CELESTIAL_REGISTRY[planet].glyph
fd_parts.append(f"{glyph} {planet}")
else:
fd_parts.append(planet)
fd_str = " ↔ ".join(fd_parts)
lines.append(f"Final Dispositor: {fd_str} (mutual reception)")
else:
fd_str = " ↔ ".join([f"House {h}" for h in result.final_dispositor])
lines.append(f"Final Dispositor: {fd_str} (mutual reception)")
else:
if result.mode == "planetary":
if result.final_dispositor in CELESTIAL_REGISTRY:
glyph = CELESTIAL_REGISTRY[result.final_dispositor].glyph
lines.append(
f"Final Dispositor: {glyph} {result.final_dispositor}"
)
else:
lines.append(f"Final Dispositor: {result.final_dispositor}")
else:
lines.append(f"Final Dispositor: House {result.final_dispositor}")
else:
lines.append("Final Dispositor: None (complex loop structure)")
# Mutual receptions
if result.mutual_receptions:
lines.append("")
lines.append("Mutual Receptions:")
for mr in result.mutual_receptions:
if result.mode == "planetary":
glyph1 = CELESTIAL_REGISTRY.get(mr.node1, {})
glyph2 = CELESTIAL_REGISTRY.get(mr.node2, {})
g1 = glyph1.glyph if hasattr(glyph1, "glyph") else ""
g2 = glyph2.glyph if hasattr(glyph2, "glyph") else ""
lines.append(f" {g1} {mr.node1} ↔ {g2} {mr.node2}")
else:
# House mode - include ruling planets
lines.append(
f" House {mr.node1} ({mr.planet1}) ↔ "
f"House {mr.node2} ({mr.planet2})"
)
# Chains (optional)
if self.show_chains and result.chains:
lines.append("")
lines.append("Disposition Chains:")
for _start, chain in sorted(result.chains.items()):
if result.mode == "planetary":
# Format with glyphs
chain_parts = []
for node in chain:
if node in CELESTIAL_REGISTRY:
chain_parts.append(CELESTIAL_REGISTRY[node].glyph)
else:
chain_parts.append(node)
chain_str = " → ".join(chain_parts)
else:
chain_str = " → ".join(chain)
lines.append(f" {chain_str}")
# Return as tuple of (title, data) for compound rendering
return (
f"{title} Dispositors",
{
"type": "text",
"content": "\n".join(lines),
},
)