"""
Report builder for creating chart reports.
The builder pattern allows users to progressively construct reports
by adding sections one at a time, then rendering in their chosen format.
"""
import datetime as dt
from typing import Any
from stellium.core.comparison import Comparison
from stellium.core.models import CalculatedChart
from stellium.core.multichart import MultiChart
from stellium.core.protocols import ReportRenderer, ReportSection
from .renderers import PlainTextRenderer, RichTableRenderer
from .sections import (
ArabicPartsSection,
AspectPatternSection,
AspectSection,
ChartOverviewSection,
CrossChartAspectSection,
DeclinationAspectSection,
DeclinationSection,
DignitySection,
DispositorSection,
EclipseSection,
FixedStarsSection,
HouseCuspsSection,
IngressSection,
MidpointAspectsSection,
MidpointSection,
MidpointTreeSection,
MoonPhaseSection,
PlanetPositionSection,
ProfectionSection,
ProfectionVisualizationSection,
StationSection,
ZodiacalReleasingSection,
ZRVisualizationSection,
)
[docs]
class ReportBuilder:
"""
Builder for chart reports.
Example::
report = (
ReportBuilder()
.from_chart(chart)
.with_chart_overview()
.with_planet_positions()
.render(format="rich_table")
)
"""
def __init__(self) -> None:
"""Initialize an empty report builder."""
self._chart: CalculatedChart | Comparison | MultiChart | None = None
self._sections: list[ReportSection] = []
self._chart_image_path: str | None = None
self._auto_generate_chart_image: bool = False
self._title: str | None = None
[docs]
def from_chart(
self, chart: CalculatedChart | Comparison | MultiChart
) -> "ReportBuilder":
"""
Set the chart to generate reports from.
Args:
chart: A CalculatedChart, Comparison, or MultiChart
Returns:
Self for chaining
"""
self._chart = chart
return self
def _is_comparison(self) -> bool:
"""Check if the current chart is a Comparison object."""
return isinstance(self._chart, Comparison)
def _is_multichart(self) -> bool:
"""Check if the current chart is a MultiChart object."""
return isinstance(self._chart, MultiChart)
[docs]
def with_chart_image(self, path: str | None = None) -> "ReportBuilder":
"""
Include a chart wheel image in the report.
When called without arguments, automatically generates a chart SVG
using the chart's default draw settings.
Args:
path: Optional path to an existing SVG file. If not provided,
a chart image will be auto-generated when rendering.
Returns:
Self for chaining
Examples:
# Auto-generate chart image
report.with_chart_image()
# Use existing SVG file
report.with_chart_image("my_chart.svg")
"""
if path:
self._chart_image_path = path
self._auto_generate_chart_image = False
else:
self._auto_generate_chart_image = True
return self
[docs]
def with_title(self, title: str) -> "ReportBuilder":
"""
Set a custom title for the report.
The title appears on the cover page of PDF reports.
If not set, a default title is generated from the chart's name.
Args:
title: Custom title string
Returns:
Self for chaining
Examples:
report.with_title("Birth Chart Analysis")
report.with_title("Albert Einstein - Complete Natal Analysis")
"""
self._title = title
return self
# -------------------------------------------------------------------------
# Section Adding Methods
# -------------------------------------------------------------------------
# Each .with_*() method adds a section to the report.
# Sections are not evaluated until render() is called.
[docs]
def with_chart_overview(self) -> "ReportBuilder":
"""
Add chart overview section (birth data, chart type, etc.).
Returns:
Self for chaining
"""
self._sections.append(ChartOverviewSection())
return self
[docs]
def with_planet_positions(
self,
include_speed: bool = False,
include_house: bool = True,
house_systems: str | list[str] = "all",
) -> "ReportBuilder":
"""
Add planet positions table.
Args:
include_speed: Show speed in longitude (for retrogrades)
include_house: Show house placement
house_systems: Which house systems to display (DEFAULT: "all")
- "all": Show all calculated systems
- list[str]: Show specific systems
- None: Show default system only
Returns:
Self for chaining
"""
self._sections.append(
PlanetPositionSection(
include_speed=include_speed,
include_house=include_house,
house_systems=house_systems,
)
)
return self
[docs]
def with_aspects(
self,
mode: str = "all",
orbs: bool = True,
sort_by: str = "orb", # or "planet" or "aspect_type"
include_aspectarian: bool = True,
aspectarian_detailed: bool = False,
aspectarian_cell_size: int | None = None,
aspectarian_theme: str | None = None,
) -> "ReportBuilder":
"""
Add aspects table with optional aspectarian grid.
Args:
mode: "all", "major", "minor", or "harmonic"
orbs: Show orb column
sort_by: How to sort aspects ("orb", "planet", or "aspect_type")
include_aspectarian: Include aspectarian grid SVG (default: True)
aspectarian_detailed: Show orb and A/S in aspectarian cells (default: False)
aspectarian_cell_size: Override cell size for aspectarian (default: config default)
aspectarian_theme: Theme for aspectarian rendering (default: None)
Returns:
Self for chaining
Note:
The aspectarian SVG is displayed in HTML/PDF output. Terminal output
shows a placeholder with dimensions.
"""
self._sections.append(
AspectSection(
mode=mode,
orbs=orbs,
sort_by=sort_by,
include_aspectarian=include_aspectarian,
aspectarian_detailed=aspectarian_detailed,
aspectarian_cell_size=aspectarian_cell_size,
aspectarian_theme=aspectarian_theme,
)
)
return self
[docs]
def with_cross_aspects(
self,
mode: str = "all",
orbs: bool = True,
sort_by: str = "orb",
) -> "ReportBuilder":
"""
Add cross-chart aspects table (for Comparison charts).
Shows aspects between chart1 planets and chart2 planets with
appropriate labels for each chart.
Args:
mode: "all", "major", "minor", or "harmonic"
orbs: Show orb column
sort_by: How to sort aspects ("orb", "planet", "aspect_type")
Returns:
Self for chaining
Note:
This section requires a Comparison object (from ComparisonBuilder).
If used with a single CalculatedChart, displays a helpful message.
Example:
>>> comparison = ComparisonBuilder.synastry(chart1, chart2).calculate()
>>> report = (ReportBuilder()
... .from_chart(comparison)
... .with_cross_aspects(mode="major")
... .render())
"""
self._sections.append(
CrossChartAspectSection(
mode=mode,
orbs=orbs,
sort_by=sort_by,
)
)
return self
[docs]
def with_midpoints(
self,
mode: str = "all",
threshold: int | None = None,
) -> "ReportBuilder":
"""
Add midpoints table.
Args:
mode: "all" or "core" (Sun/Moon/ASC/MC midpoints)
threshold: Only show top N midpoints by importance
Returns:
Self for chaining
"""
self._sections.append(
MidpointSection(
mode=mode,
threshold=threshold,
)
)
return self
[docs]
def with_midpoint_aspects(
self,
mode: str = "conjunction",
orb: float = 1.5,
midpoint_filter: str = "all",
sort_by: str = "orb",
) -> "ReportBuilder":
"""
Add planets aspecting midpoints table.
This shows which planets activate which midpoints - the most useful
way to interpret midpoints. Typically only conjunctions matter (1-2° orb).
Args:
mode: Which aspects to check (DEFAULT: "conjunction")
- "conjunction": Only conjunctions (most common, recommended)
- "hard": Conjunction, square, opposition
- "all": All major aspects
orb: Maximum orb in degrees (DEFAULT: 1.5°)
Midpoints use tighter orbs than regular aspects.
midpoint_filter: Which midpoints to check (DEFAULT: "all")
- "all": All calculated midpoints
- "core": Only Sun/Moon/ASC/MC midpoints
sort_by: Sort order (DEFAULT: "orb")
- "orb": Tightest aspects first
- "planet": Group by aspecting planet
- "midpoint": Group by midpoint
Returns:
Self for chaining
Example:
>>> # Show planets conjunct any midpoint within 1.5°
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_midpoint_aspects()
... .render())
>>>
>>> # Show hard aspects to core midpoints only
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_midpoint_aspects(
... mode="hard",
... midpoint_filter="core",
... orb=2.0
... )
... .render())
Note:
Requires MidpointCalculator to be added to chart builder:
chart = (ChartBuilder.from_native(native)
.add_component(MidpointCalculator())
.calculate())
"""
self._sections.append(
MidpointAspectsSection(
mode=mode,
orb=orb,
midpoint_filter=midpoint_filter,
sort_by=sort_by,
)
)
return self
[docs]
def with_midpoint_trees(
self,
tree_bases: list[str] | None = None,
branch_objects: list[str] | None = None,
orb: float = 1.5,
aspect_mode: str = "conjunction",
output: str = "both",
) -> "ReportBuilder":
"""
Add midpoint tree visualization section.
Generates tree diagrams showing which midpoints aspect focal points.
This is a standard Uranian/Hamburg astrology technique for
interpreting planetary pictures.
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 (DEFAULT: "conjunction")
- "conjunction": Only conjunctions (0°)
- "hard": Conjunction + 45° series (0°, 45°, 90°, 135°, 180°)
- "all": All major aspects
output: What to generate (DEFAULT: "both")
- "svg": Just SVG visualization
- "text": Just text output
- "both": Both SVG and text
Returns:
Self for chaining
Example:
>>> # Show midpoint trees with hard aspects
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_midpoint_trees(aspect_mode="hard")
... .render())
>>>
>>> # Custom focal points with conjunction only
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_midpoint_trees(
... tree_bases=["Sun", "Moon"],
... orb=2.0,
... aspect_mode="conjunction"
... )
... .render())
Note:
Requires MidpointCalculator to be added to chart builder:
chart = (ChartBuilder.from_native(native)
.add_component(MidpointCalculator())
.calculate())
"""
self._sections.append(
MidpointTreeSection(
tree_bases=tree_bases,
branch_objects=branch_objects,
orb=orb,
aspect_mode=aspect_mode,
output=output,
)
)
return self
[docs]
def with_arabic_parts(
self,
mode: str = "all",
show_formula: bool = True,
show_description: bool = False,
) -> "ReportBuilder":
"""
Add Arabic Parts (Lots) table.
Args:
mode: Which parts to display (DEFAULT: "all")
- "all": All calculated parts
- "core": 7 Hermetic Lots (Fortune, Spirit, Eros, etc.)
- "family": Family & Relationship Lots
- "life": Life Topic Lots
- "planetary": Planetary Exaltation Lots
show_formula: Include the formula column (DEFAULT: True)
Formula shows as "ASC + Point2 - Point3" with * for sect-aware parts
show_description: Include part descriptions (DEFAULT: False)
Returns:
Self for chaining
Example:
>>> # Show all Arabic Parts with formulas
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_arabic_parts()
... .render())
>>>
>>> # Show only core Hermetic Lots with descriptions
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_arabic_parts(
... mode="core",
... show_description=True
... )
... .render())
Note:
Requires ArabicPartsCalculator to be added to chart builder:
from stellium.components.arabic_parts import ArabicPartsCalculator
chart = (ChartBuilder.from_native(native)
.add_component(ArabicPartsCalculator())
.calculate())
"""
self._sections.append(
ArabicPartsSection(
mode=mode,
show_formula=show_formula,
show_description=show_description,
)
)
return self
[docs]
def with_house_cusps(self, systems: str | list[str] = "all") -> "ReportBuilder":
"""
Add house cusps table.
Args:
systems: Which house systems to display (DEFAULT: "all")
- "all": Show all calculated systems
- list[str]: Show specific systems
Returns:
Self for chaining
"""
self._sections.append(HouseCuspsSection(systems=systems))
return self
[docs]
def with_dignities(
self,
essential: str = "both",
show_details: bool = False,
) -> "ReportBuilder":
"""
Add essential dignities table.
Args:
essential: Which essential dignity system(s) to show (DEFAULT: "both")
- "traditional": Traditional dignities only
- "modern": Modern dignities only
- "both": Both systems
- "none": Skip essential dignities
show_details: Show dignity names instead of just scores
Returns:
Self for chaining
Note:
Requires DignityComponent to be added to chart builder.
If missing, displays helpful message instead of erroring.
"""
self._sections.append(
DignitySection(
essential=essential,
show_details=show_details,
)
)
return self
[docs]
def with_aspect_patterns(
self,
pattern_types: str | list[str] = "all",
sort_by: str = "type",
) -> "ReportBuilder":
"""
Add aspect patterns table (Grand Trines, T-Squares, Yods, etc.).
Args:
pattern_types: Which pattern types to show (DEFAULT: "all")
- "all": Show all detected patterns
- list[str]: Show specific pattern types
sort_by: How to sort patterns (DEFAULT: "type")
- "type": Group by pattern type
- "element": Group by element
- "count": Sort by number of planets
Returns:
Self for chaining
Note:
Requires AspectPatternAnalyzer to be added to chart builder.
If missing, displays helpful message instead of erroring.
"""
self._sections.append(
AspectPatternSection(
pattern_types=pattern_types,
sort_by=sort_by,
)
)
return self
[docs]
def with_profections(
self,
age: int | None = None,
date: str | None = None,
include_monthly: bool = True,
include_multi_point: bool = True,
include_timeline: bool = False,
timeline_range: tuple[int, int] | None = None,
points: list[str] | None = None,
house_system: str | None = None,
rulership: str = "traditional",
) -> "ReportBuilder":
"""
Add profection timing analysis section.
Profections are a Hellenistic technique where the ASC advances
one sign per year. The planet ruling that sign becomes the
"Lord of the Year."
Args:
age: Age for profection (either age OR date required)
date: Target date as ISO string (e.g., "2025-06-15")
include_monthly: Show monthly profection when date is provided
include_multi_point: Show lords for ASC, Sun, Moon, MC
include_timeline: Show timeline table of Lords
timeline_range: Custom range for timeline (e.g., (25, 40))
points: Custom points for multi-point analysis
house_system: House system to use (default: prefers Whole Sign)
rulership: "traditional" or "modern"
Returns:
Self for chaining
Example::
# By age
report = (
ReportBuilder()
.from_chart(chart)
.with_profections(age=30)
.render()
)
# By date with timeline
report = (
ReportBuilder()
.from_chart(chart)
.with_profections(date="2025-06-15", include_timeline=True)
.render()
)
"""
self._sections.append(
ProfectionSection(
age=age,
date=date,
include_monthly=include_monthly,
include_multi_point=include_multi_point,
include_timeline=include_timeline,
timeline_range=timeline_range,
points=points,
house_system=house_system,
rulership=rulership,
)
)
return self
[docs]
def with_zodiacal_releasing(
self,
lots: str | list[str] | None = None,
mode: str = "both",
query_date: str | None = None,
query_age: float | None = None,
context_periods: int = 2,
) -> "ReportBuilder":
"""
Add Zodiacal Releasing timing analysis section.
Zodiacal Releasing is a Hellenistic predictive technique that divides
life into major periods ruled by signs, showing when different life
themes are activated.
Args:
lots: Which lot(s) to display:
- str: Single lot name (e.g., "Part of Fortune")
- list[str]: Multiple lots (e.g., ["Part of Fortune", "Part of Spirit"])
- None: All lots calculated in the chart (DEFAULT)
mode: Display mode:
- "snapshot": Current periods only
- "timeline": L1 timeline only
- "both": Both snapshot and timeline (DEFAULT)
query_date: Date for snapshot as ISO string (defaults to now)
query_age: Age for snapshot (alternative to query_date)
context_periods: Number of L3/L4 periods to show before/after current (default: 2)
Returns:
Self for chaining
Note:
Requires ZodiacalReleasingAnalyzer to be added during chart calculation:
from stellium.engines.releasing import ZodiacalReleasingAnalyzer
chart = (
ChartBuilder.from_native(native)
.add_analyzer(ZodiacalReleasingAnalyzer(["Part of Fortune", "Part of Spirit"]))
.calculate()
)
Example::
# Show current ZR state for all calculated lots
report = (
ReportBuilder()
.from_chart(chart)
.with_zodiacal_releasing()
.render()
)
# Show ZR for specific lot at specific age
report = (
ReportBuilder()
.from_chart(chart)
.with_zodiacal_releasing(
lots="Part of Fortune",
mode="snapshot",
query_age=30
)
.render()
)
# Show only L1 timeline for Fortune and Spirit
report = (
ReportBuilder()
.from_chart(chart)
.with_zodiacal_releasing(
lots=["Part of Fortune", "Part of Spirit"],
mode="timeline"
)
.render()
)
"""
self._sections.append(
ZodiacalReleasingSection(
lots=lots,
mode=mode,
query_date=query_date,
query_age=query_age,
context_periods=context_periods,
)
)
return self
[docs]
def with_zr_visualization(
self,
lot: str = "Part of Fortune",
year: int | None = None,
levels: tuple[int, ...] = (1, 2, 3),
output: str = "both",
) -> "ReportBuilder":
"""
Add Zodiacal Releasing visualization (SVG timeline diagram).
Generates visual timeline diagrams in Honeycomb Collective style:
- Overview page: natal angles chart + period length reference
- Timeline page: stacked L1/L2/L3 timelines with peak shapes
Args:
lot: Which lot to visualize (default: "Part of Fortune")
year: Year to visualize (defaults to current year)
levels: Which levels to show in timeline (default: 1, 2, 3)
output: What to generate:
- "overview": Just the overview page
- "timeline": Just the timeline visualization
- "both": Both pages (DEFAULT)
Returns:
Self for chaining
Note:
Requires ZodiacalReleasingAnalyzer to be added during chart calculation:
from stellium.engines.releasing import ZodiacalReleasingAnalyzer
chart = (
ChartBuilder.from_native(native)
.add_analyzer(ZodiacalReleasingAnalyzer(["Part of Fortune"]))
.calculate()
)
Example::
# Add ZR visualization to PDF report
report = (
ReportBuilder()
.from_chart(chart)
.with_chart_overview()
.with_zr_visualization(lot="Part of Fortune", year=2025)
.render(format="pdf", file="report.pdf")
)
"""
self._sections.append(
ZRVisualizationSection(
lot=lot,
year=year,
levels=levels,
output=output,
)
)
return self
[docs]
def with_profections_wheel(
self,
age: int | None = None,
date: str | None = None,
compare_ages: list[int] | None = None,
show_wheel: bool = True,
show_table: bool = True,
house_system: str | None = None,
rulership: str = "traditional",
) -> "ReportBuilder":
"""
Add profection wheel visualization section.
Generates a visual wheel diagram showing annual profections:
- Circular wheel with ages 0-95 spiraling through 12 houses
- Zodiac signs and house labels around the perimeter
- Natal planet positions marked on the wheel
- Current age highlighted
- Summary table with profection details
Args:
age: Current age to highlight (either age OR date required)
date: Target date as ISO string (e.g., "2025-06-15")
compare_ages: List of ages to compare in table (default: current and next)
show_wheel: Whether to show the wheel visualization (default: True)
show_table: Whether to show the summary table (default: True)
house_system: House system to use (default: prefers Whole Sign)
rulership: "traditional" or "modern"
Returns:
Self for chaining
Example::
# By age with both wheel and table
report = (
ReportBuilder()
.from_chart(chart)
.with_profections_wheel(age=30)
.render(format="pdf", file="profections.pdf")
)
# Compare specific ages
report = (
ReportBuilder()
.from_chart(chart)
.with_profections_wheel(
age=30,
compare_ages=[30, 31, 32]
)
.render()
)
"""
self._sections.append(
ProfectionVisualizationSection(
age=age,
date=date,
compare_ages=compare_ages,
show_wheel=show_wheel,
show_table=show_table,
house_system=house_system,
rulership=rulership,
)
)
return self
[docs]
def with_section(self, section: ReportSection) -> "ReportBuilder":
"""
Add a custom section.
This allows users to extend the report system with their own sections.
Args:
section: Any object implementing the ReportSection protocol
Returns:
Self for chaining
Example::
class MyCustomSection:
@property
def section_name(self) -> str:
return "My Analysis"
def generate_data(self, chart: CalculatedChart) -> dict:
return {"type": "text", "text": "Custom analysis..."}
report = (
ReportBuilder()
.from_chart(chart)
.with_section(MyCustomSection())
.render()
)
"""
self._sections.append(section)
return self
[docs]
def with_moon_phase(self) -> "ReportBuilder":
"""Add moon phase section."""
self._sections.append(MoonPhaseSection())
return self
[docs]
def with_declinations(self) -> "ReportBuilder":
"""
Add declinations table.
Shows planetary declinations (distance from celestial equator),
direction (north/south), and out-of-bounds status.
Out-of-bounds planets have declination beyond the Sun's maximum
(~23°27') and are considered to have extra intensity or unconventional
expression.
Returns:
Self for chaining
Example:
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_chart_overview()
... .with_declinations()
... .render())
"""
self._sections.append(DeclinationSection())
return self
[docs]
def with_declination_aspects(
self,
mode: str = "all",
show_orbs: bool = True,
show_oob_status: bool = True,
sort_by: str = "orb",
) -> "ReportBuilder":
"""
Add declination aspects table (Parallel and Contraparallel).
Declination aspects are based on equatorial coordinates rather than
ecliptic longitude. They represent a different type of planetary
relationship.
- Parallel: Two planets at the same declination (same hemisphere).
Interpreted like a conjunction.
- Contraparallel: Two planets at equal declination but opposite
hemispheres. Interpreted like an opposition.
Args:
mode: Which aspects to show (DEFAULT: "all")
- "all": Both parallel and contraparallel
- "parallel": Only parallel aspects
- "contraparallel": Only contraparallel aspects
show_orbs: Show orb column (DEFAULT: True)
show_oob_status: Show out-of-bounds status (DEFAULT: True)
sort_by: How to sort aspects (DEFAULT: "orb")
- "orb": Tightest aspects first
- "planet": Group by planet
- "aspect_type": Group by Parallel/Contraparallel
Returns:
Self for chaining
Note:
Requires .with_declination_aspects() on ChartBuilder:
chart = (ChartBuilder.from_native(native)
.with_aspects()
.with_declination_aspects(orb=1.0)
.calculate())
Example:
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_chart_overview()
... .with_declination_aspects(mode="all")
... .render())
"""
self._sections.append(
DeclinationAspectSection(
mode=mode,
show_orbs=show_orbs,
show_oob_status=show_oob_status,
sort_by=sort_by,
)
)
return self
[docs]
def with_dispositors(
self,
mode: str = "both",
rulership: str = "traditional",
house_system: str | None = None,
show_chains: bool = True,
) -> "ReportBuilder":
"""
Add dispositor analysis section.
Shows planetary and/or house-based dispositor chains, final dispositor(s),
and mutual receptions.
Args:
mode: Which dispositor analysis to show (DEFAULT: "both")
- "planetary": Traditional planet-disposes-planet
- "house": Kate's house-based innovation (life area flow)
- "both": Show both analyses
rulership: "traditional" or "modern" rulership system (DEFAULT: "traditional")
house_system: House system for house-based mode (defaults to chart's default)
show_chains: Whether to show full disposition chain details (DEFAULT: True)
Returns:
Self for chaining
Example:
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_chart_overview()
... .with_dispositors(mode="both")
... .render())
Note:
For graphical output (SVG), use the DispositorEngine directly:
from stellium.engines.dispositors import DispositorEngine, render_both_dispositors
engine = DispositorEngine(chart)
graph = render_both_dispositors(engine.planetary(), engine.house_based())
graph.render("dispositors", format="svg")
"""
self._sections.append(
DispositorSection(
mode=mode,
rulership=rulership,
house_system=house_system,
show_chains=show_chains,
)
)
return self
[docs]
def with_fixed_stars(
self,
tier: int | None = None,
include_keywords: bool = True,
sort_by: str = "longitude",
) -> "ReportBuilder":
"""
Add fixed stars table.
Shows positions and metadata for fixed stars in the chart.
Requires FixedStarsComponent to be added to chart builder.
Args:
tier: Filter to specific tier (DEFAULT: None = all tiers)
- 1: Royal Stars only (Aldebaran, Regulus, Antares, Fomalhaut)
- 2: Major Stars only
- 3: Extended Stars only
- None: All tiers
include_keywords: Include interpretive keywords column (DEFAULT: True)
sort_by: Sort order (DEFAULT: "longitude")
- "longitude": Zodiacal order
- "magnitude": Brightest first
- "tier": Royal first, then Major, then Extended
Returns:
Self for chaining
Example:
>>> # Royal stars only
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_fixed_stars(tier=1)
... .render())
>>>
>>> # All stars sorted by brightness
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_fixed_stars(sort_by="magnitude")
... .render())
Note:
Requires FixedStarsComponent to be added to chart builder:
chart = (ChartBuilder.from_native(native)
.add_component(FixedStarsComponent())
.calculate())
"""
self._sections.append(
FixedStarsSection(
tier=tier,
include_keywords=include_keywords,
sort_by=sort_by,
)
)
return self
[docs]
def with_stations(
self,
end: dt.datetime,
start: dt.datetime | None = None,
planets: list[str] | None = None,
include_minor: bool = False,
) -> "ReportBuilder":
"""
Add planetary stations (retrograde/direct) table.
Shows when planets station retrograde or direct within a date range.
Useful for retrograde calendars and transit planning.
Args:
end: End date for station search (required)
start: Start date for station search (optional, defaults to chart date)
planets: Which planets to include (default: Mercury through Pluto)
include_minor: Include Chiron (default: False)
Returns:
Self for chaining
Example:
>>> # Stations for the next year from chart date
>>> from datetime import datetime, timedelta
>>> chart_date = chart.datetime.utc_datetime
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_stations(end=chart_date + timedelta(days=365))
... .render())
>>>
>>> # Specific date range
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_stations(
... start=datetime(2025, 1, 1),
... end=datetime(2025, 12, 31)
... )
... .render())
"""
# Use chart date as start if not provided
if start is None:
if self._chart is None:
raise ValueError(
"Must call from_chart() before with_stations() when start is not provided"
)
if isinstance(self._chart, Comparison):
start = self._chart.chart1.datetime.utc_datetime
else:
start = self._chart.datetime.utc_datetime
self._sections.append(
StationSection(
start=start,
end=end,
planets=planets,
include_minor=include_minor,
)
)
return self
[docs]
def with_ingresses(
self,
end: dt.datetime,
start: dt.datetime | None = None,
planets: list[str] | None = None,
include_moon: bool = False,
include_minor: bool = False,
) -> "ReportBuilder":
"""
Add sign ingresses table.
Shows when planets enter new zodiac signs within a date range.
Useful for tracking sign changes and transit planning.
Args:
end: End date for ingress search (required)
start: Start date for ingress search (optional, defaults to chart date)
planets: Which planets to include (default: Sun through Pluto)
include_moon: Include Moon ingresses (default: False, very frequent)
include_minor: Include Chiron (default: False)
Returns:
Self for chaining
Example:
>>> # Ingresses for the next year from chart date
>>> from datetime import datetime, timedelta
>>> chart_date = chart.datetime.utc_datetime
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_ingresses(end=chart_date + timedelta(days=365))
... .render())
>>>
>>> # Specific date range with Moon included
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_ingresses(
... start=datetime(2025, 1, 1),
... end=datetime(2025, 12, 31),
... include_moon=True
... )
... .render())
"""
# Use chart date as start if not provided
if start is None:
if self._chart is None:
raise ValueError(
"Must call from_chart() before with_ingresses() when start is not provided"
)
if isinstance(self._chart, Comparison):
start = self._chart.chart1.datetime.utc_datetime
else:
start = self._chart.datetime.utc_datetime
self._sections.append(
IngressSection(
start=start,
end=end,
planets=planets,
include_moon=include_moon,
include_minor=include_minor,
)
)
return self
[docs]
def with_eclipses(
self,
end: dt.datetime,
start: dt.datetime | None = None,
eclipse_types: str = "both",
) -> "ReportBuilder":
"""
Add eclipses table.
Shows solar and lunar eclipses within a date range.
Useful for eclipse calendars and transit planning.
Args:
end: End date for eclipse search (required)
start: Start date for eclipse search (optional, defaults to chart date)
eclipse_types: Which types to include ("both", "solar", "lunar")
Returns:
Self for chaining
Example:
>>> # Eclipses for the next 2 years from chart date
>>> from datetime import datetime, timedelta
>>> chart_date = chart.datetime.utc_datetime
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_eclipses(end=chart_date + timedelta(days=730))
... .render())
>>>
>>> # Only solar eclipses in a specific range
>>> report = (ReportBuilder()
... .from_chart(chart)
... .with_eclipses(
... start=datetime(2025, 1, 1),
... end=datetime(2025, 12, 31),
... eclipse_types="solar"
... )
... .render())
"""
# Use chart date as start if not provided
if start is None:
if self._chart is None:
raise ValueError(
"Must call from_chart() before with_eclipses() when start is not provided"
)
if isinstance(self._chart, Comparison):
start = self._chart.chart1.datetime.utc_datetime
else:
start = self._chart.datetime.utc_datetime
self._sections.append(
EclipseSection(
start=start,
end=end,
eclipse_types=eclipse_types,
)
)
return self
# -------------------------------------------------------------------------
# Preset Methods
# -------------------------------------------------------------------------
# Convenience methods that bundle multiple sections into common configurations.
[docs]
def preset_minimal(self) -> "ReportBuilder":
"""
Minimal preset: Just the basics.
Includes:
- Chart overview (name, date, location)
- Planet positions
Returns:
Self for chaining
Example:
>>> report = ReportBuilder().from_chart(chart).preset_minimal().render()
"""
return self.with_chart_overview().with_planet_positions()
[docs]
def preset_standard(self) -> "ReportBuilder":
"""
Standard preset: Common report sections for everyday use.
Includes:
- Chart overview
- Planet positions (with house placements)
- Major aspects (sorted by orb)
- House cusps
Returns:
Self for chaining
Example:
>>> report = ReportBuilder().from_chart(chart).preset_standard().render()
"""
return (
self.with_chart_overview()
.with_planet_positions(include_house=True)
.with_aspects(mode="major")
.with_house_cusps()
)
[docs]
def preset_detailed(self) -> "ReportBuilder":
"""
Detailed preset: Comprehensive report with all major sections.
Includes:
- Chart overview
- Moon phase
- Planet positions (with speed and all house systems)
- Declinations
- All aspects (sorted by orb)
- House cusps
- Essential dignities
Returns:
Self for chaining
Example:
>>> report = ReportBuilder().from_chart(chart).preset_detailed().render()
"""
return (
self.with_chart_overview()
.with_moon_phase()
.with_planet_positions(include_speed=True, include_house=True)
.with_declinations()
.with_aspects(mode="all")
.with_house_cusps()
.with_dignities()
)
[docs]
def preset_full(self) -> "ReportBuilder":
"""
Full preset: Everything available.
Includes all sections for maximum detail:
- Chart overview
- Moon phase
- Planet positions (with speed and all house systems)
- Declinations
- All aspects
- Aspect patterns (Grand Trines, T-Squares, etc.)
- House cusps
- Essential dignities
- Midpoints and midpoint aspects
- Fixed stars
- Zodiacal Releasing (Part of Fortune and Part of Spirit)
Note: Some sections require specific components to be added during
chart calculation (e.g., DignityComponent, AspectPatternAnalyzer,
MidpointCalculator, FixedStarsComponent, ZodiacalReleasingAnalyzer).
Missing components show helpful messages rather than errors.
Returns:
Self for chaining
Example:
>>> chart = (ChartBuilder.from_native(native)
... .with_aspects()
... .add_component(DignityComponent())
... .add_analyzer(AspectPatternAnalyzer())
... .add_component(MidpointCalculator())
... .add_component(FixedStarsComponent())
... .add_analyzer(ZodiacalReleasingAnalyzer(["Part of Fortune", "Part of Spirit"]))
... .calculate())
>>> report = ReportBuilder().from_chart(chart).preset_full().render()
"""
return (
self.with_chart_overview()
.with_moon_phase()
.with_planet_positions(include_speed=True, include_house=True)
.with_house_cusps()
.with_aspects(mode="all")
.with_aspect_patterns()
.with_dignities(show_details=True)
.with_dispositors()
.with_declinations()
.with_declination_aspects()
.with_midpoints()
.with_midpoint_aspects()
.with_fixed_stars()
.with_zodiacal_releasing(
lots=["Part of Fortune", "Part of Spirit"],
mode="both",
)
)
[docs]
def preset_positions_only(self) -> "ReportBuilder":
"""
Positions-only preset: Focus on planetary placements.
Includes:
- Chart overview
- Planet positions (with speed and house placements)
- Declinations
- House cusps
No aspects or interpretive sections.
Returns:
Self for chaining
Example:
>>> report = ReportBuilder().from_chart(chart).preset_positions_only().render()
"""
return (
self.with_chart_overview()
.with_planet_positions(include_speed=True, include_house=True)
.with_declinations()
.with_house_cusps()
)
[docs]
def preset_aspects_only(self) -> "ReportBuilder":
"""
Aspects-only preset: Focus on planetary relationships.
Includes:
- Chart overview
- All aspects (with orbs)
- Aspect patterns (Grand Trines, T-Squares, etc.)
Returns:
Self for chaining
Note: Aspect patterns require AspectPatternAnalyzer component.
Example:
>>> report = ReportBuilder().from_chart(chart).preset_aspects_only().render()
"""
return (
self.with_chart_overview()
.with_aspects(mode="all", orbs=True)
.with_aspect_patterns()
)
[docs]
def preset_synastry(self) -> "ReportBuilder":
"""
Synastry preset: Optimized for relationship comparison charts.
Designed for Comparison objects, this preset shows:
- Chart overview (displays both charts' info)
- Planet positions (side-by-side tables for each chart)
- Cross-chart aspects (with chart labels)
- House cusps (side-by-side tables for each chart)
Returns:
Self for chaining
Example:
>>> comparison = ComparisonBuilder.synastry(chart1, chart2).calculate()
>>> report = ReportBuilder().from_chart(comparison).preset_synastry().render()
"""
return (
self.with_chart_overview()
.with_planet_positions(include_house=True)
.with_cross_aspects(mode="major")
.with_house_cusps()
)
[docs]
def preset_transit(self) -> "ReportBuilder":
"""
Transit preset: Optimized for transit comparison charts.
Shows natal chart positions alongside transit positions,
with cross-chart aspects showing transiting planets'
aspects to natal positions.
Includes:
- Chart overview
- Planet positions (side-by-side: natal vs transit)
- Cross-chart aspects (all aspects, tight orbs)
- House cusps (side-by-side)
Returns:
Self for chaining
Example:
>>> transit = ComparisonBuilder.transit(natal, transit_time).calculate()
>>> report = ReportBuilder().from_chart(transit).preset_transit().render()
"""
return (
self.with_chart_overview()
.with_planet_positions(include_house=True)
.with_cross_aspects(mode="all")
.with_house_cusps()
)
[docs]
def preset_transit_calendar(
self,
end: dt.datetime,
start: dt.datetime | None = None,
include_minor_planets: bool = False,
) -> "ReportBuilder":
"""
Transit calendar preset: Sky events over a date range.
Bundles all three transit calendar sections showing what's
happening in the sky between two dates. Useful for planning
around retrogrades, sign changes, and eclipses.
Includes:
- Planetary stations (retrograde/direct)
- Sign ingresses (planets changing signs)
- Eclipses (solar and lunar)
Args:
end: End date for the calendar (required)
start: Start date (optional, defaults to chart date)
include_minor_planets: Include Chiron in stations/ingresses (default: False)
Returns:
Self for chaining
Example:
>>> # Transit calendar for the next year from chart date
>>> from datetime import timedelta
>>> chart_date = chart.datetime.utc_datetime
>>> report = (ReportBuilder()
... .from_chart(chart)
... .preset_transit_calendar(end=chart_date + timedelta(days=365))
... .render())
>>>
>>> # Specific date range
>>> from datetime import datetime
>>> report = (ReportBuilder()
... .from_chart(chart)
... .preset_transit_calendar(
... start=datetime(2025, 1, 1),
... end=datetime(2025, 12, 31)
... )
... .render())
Note:
This preset does NOT include natal chart information - it's purely
about sky events. For transits TO your natal chart, use
ComparisonBuilder.transit() with preset_transit() instead.
"""
return (
self.with_stations(
end=end, start=start, include_minor=include_minor_planets
)
.with_ingresses(end=end, start=start, include_minor=include_minor_planets)
.with_eclipses(end=end, start=start)
)
# -------------------------------------------------------------------------
# Rendering Methods
# -------------------------------------------------------------------------
[docs]
def render(
self,
format: str = "rich_table",
file: str | None = None,
show: bool | None = None,
) -> str | None:
"""
Render the report with flexible output options.
Args:
format: Output format ("rich_table", "plain_table", "text", "prose", "pdf", "html")
file: Optional filename to save to
show: Whether to display in terminal. Defaults to True for terminal
formats (rich_table, plain_table, text, prose) and False for file
formats (pdf, html).
Returns:
Filename if saved to file, None otherwise
Raises:
ValueError: If no chart has been set
ValueError: If unknown format specified
Examples:
# Show in terminal with Rich formatting
report.render()
# Save to file (with terminal preview)
report.render(format="plain_table", file="chart.txt")
# Save quietly (no terminal output)
report.render(format="plain_table", file="chart.txt", show=False)
# Generate PDF with chart image and title (configured via builder)
report.with_chart_image().with_title("My Report").render(
format="pdf", file="report.pdf"
)
"""
if not self._chart:
raise ValueError("No chart set. Call .from_chart(chart) before rendering.")
# Terminal-friendly formats
terminal_formats = {"rich_table", "plain_table", "text", "prose"}
# Default show behavior: True for terminal formats, False for file formats
if show is None:
show = format in terminal_formats
# Resolve chart image path (auto-generate if requested)
chart_svg_path = self._resolve_chart_image_path(file)
# Resolve title (use instance var or generate default)
title = self._title
# Generate section data once
section_data = [
(section.section_name, section.generate_data(self._chart))
for section in self._sections
]
# Show in terminal if requested and format supports it
if show and format in terminal_formats:
self._print_to_console(section_data, format)
# Save to file if requested
if file:
# Handle PDF format (binary output via Typst)
if format == "pdf":
content = self._to_typst_pdf(section_data, chart_svg_path, title)
with open(file, "wb") as f:
f.write(content)
else:
content = self._to_string(section_data, format, chart_svg_path)
with open(file, "w", encoding="utf-8") as f:
f.write(content)
return file
return None
def _resolve_chart_image_path(self, output_file: str | None) -> str | None:
"""
Resolve the chart image path for rendering.
If a path was explicitly set via with_chart_image(path), use that.
If auto-generate was requested via with_chart_image(), generate a temp SVG.
Otherwise return None.
Args:
output_file: The output file path (used to determine temp file location)
Returns:
Path to chart SVG file, or None
"""
import os
import tempfile
# Explicit path provided
if self._chart_image_path:
return self._chart_image_path
# Auto-generate requested
if self._auto_generate_chart_image and self._chart:
# Generate temp file path based on output file or use system temp
if output_file:
base_dir = os.path.dirname(os.path.abspath(output_file))
base_name = os.path.splitext(os.path.basename(output_file))[0]
svg_path = os.path.join(base_dir, f"{base_name}_chart.svg")
else:
# Use temp directory
fd, svg_path = tempfile.mkstemp(suffix=".svg", prefix="stellium_chart_")
os.close(fd)
# Generate the chart
self._chart.draw(svg_path).preset_standard().save()
return svg_path
return None
def _to_string(
self,
section_data: list[tuple[str, dict[str, Any]]],
format: str,
chart_svg_path: str | None = None,
) -> str:
"""
Convert report to plaintext string (internal helper).
Used for file saving and testing. Always returns text without ANSI codes.
Args:
section_data: List of (section_name, section_dict) tuples
format: Output format
chart_svg_path: Optional path to chart SVG (for HTML format)
Returns:
Plaintext string representation
"""
# Map format names to renderer methods
if format in ("rich_table", "plain_table", "text"):
# For terminal formats, use PlainTextRenderer for file output
# (or use RichTableRenderer.render_report which strips ANSI)
if format == "rich_table":
# Use Rich renderer's string method (strips ANSI)
renderer = RichTableRenderer()
return renderer.render_report(section_data)
else:
# Use plain text renderer
renderer = PlainTextRenderer()
return renderer.render_report(section_data)
elif format == "prose":
# Natural language prose (for pasting into conversations)
from stellium.presentation.renderers import ProseRenderer
renderer = ProseRenderer()
return renderer.render_report(section_data)
elif format == "html":
# HTML renderer
from stellium.presentation.renderers import HTMLRenderer
renderer = HTMLRenderer()
# Load SVG if path provided
svg_content = None
if chart_svg_path:
try:
with open(chart_svg_path) as f:
svg_content = f.read()
except Exception:
pass # Silently skip if can't load
return renderer.render_report(section_data, svg_content)
else:
available = "rich_table, plain_table, text, prose, pdf, html, typst"
raise ValueError(f"Unknown format '{format}'. Available: {available}")
def _to_typst_pdf(
self,
section_data: list[tuple[str, dict[str, Any]]],
chart_svg_path: str | None = None,
title: str | None = None,
) -> bytes:
"""
Convert report to PDF bytes using Typst (internal helper).
Typst produces beautiful, professional-quality PDFs with proper
typography, kerning, and hyphenation.
Args:
section_data: List of (section_name, section_dict) tuples
chart_svg_path: Optional path to chart SVG to embed
title: Optional report title (uses chart's name if not provided)
Returns:
PDF as bytes
"""
from stellium.presentation.renderers import TypstRenderer
# Build title from chart name if not provided
if title is None and self._chart:
chart_name = self._chart.metadata.get("name")
if chart_name:
title = f"{chart_name} — Natal Chart" # em dash
else:
title = "Natal Chart Report"
renderer = TypstRenderer()
return renderer.render_report(
section_data,
chart_svg_path=chart_svg_path,
title=title or "Astrological Report",
)
def _print_to_console(
self, section_data: list[tuple[str, dict[str, Any]]], format: str
) -> None:
"""
Print report directly to console (internal helper).
Args:
section_data: List of (section_name, section_dict) tuples
format: Output format (must be terminal-friendly)
"""
if format == "rich_table":
# Use Rich renderer's print method (preserves ANSI formatting)
renderer = RichTableRenderer()
renderer.print_report(section_data)
elif format in ("plain_table", "text"):
# Use plain text renderer and print the result
renderer = PlainTextRenderer()
output = renderer.render_report(section_data)
print(output)
elif format == "prose":
# Natural language prose output
from stellium.presentation.renderers import ProseRenderer
renderer = ProseRenderer()
output = renderer.render_report(section_data)
print(output)
else:
raise ValueError(
f"Format '{format}' is not terminal-friendly. "
f"Use 'rich_table', 'plain_table', 'text', or 'prose'."
)
def _get_renderer(self, format: str) -> ReportRenderer:
"""
Get the appropriate renderer for the format.
Why a factory method?
- Centralizes renderer selection logic
- Easy to add new renderers
- Can implement caching if needed
Args:
format: Renderer name
Returns:
Renderer instance
Raises:
ValueError: If format is unknown
"""
renderers = {
"rich_table": RichTableRenderer(),
"plaintext": PlainTextRenderer(),
# Future: "html": HTMLRenderer(),
# Future: "markdown": MarkdownRenderer(),
}
if format not in renderers:
available = ", ".join(renderers.keys())
raise ValueError(f"Unknown format '{format}'. Available: {available}")
return renderers[format]