"""
Unified MultiChart implementation for 2-4 chart comparisons.
This module provides a single interface for all multi-chart scenarios:
- Synastry (two natal charts)
- Transits (natal + current sky)
- Progressions (natal + progressed)
- Arc Directions (natal + directed)
- Triwheels (3 charts)
- Quadwheels (4 charts)
The MultiChart class combines the features of the former Comparison and MultiWheel
classes into a unified architecture.
Ring order (center -> out):
- Tiny aspect center
- Chart 1 ring (innermost) - primary/natal chart
- Chart 2 ring
- Chart 3 ring (if present)
- Chart 4 ring (if present)
- Zodiac ring (outermost)
"""
import datetime as dt
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Literal
from stellium.core.builder import ChartBuilder
from stellium.core.models import (
Aspect,
CalculatedChart,
CelestialPosition,
ComparisonType,
HouseOverlay,
ObjectType,
)
from stellium.core.native import Native
from stellium.core.protocols import OrbEngine
if TYPE_CHECKING:
from stellium.visualization.builder import ChartDrawBuilder
[docs]
@dataclass(frozen=True)
class MultiChart:
"""
Unified multi-chart container supporting 2-4 charts with analysis and visualization.
Supports all chart relationship types:
- Synastry (two natal charts)
- Transits (natal + transit sky)
- Progressions (natal + progressed)
- Arc Directions (natal + directed)
- Triwheels/Quadwheels (3-4 charts)
Access charts via:
- Indexed: mc[0], mc[1], mc.charts[2]
- Named: mc.chart1, mc.chart2, mc.chart3, mc.chart4
- Semantic: mc.inner, mc.outer, mc.natal
Attributes:
charts: Tuple of 2-4 CalculatedChart objects
labels: Display labels for each chart
relationships: Per-pair relationship types {(0,1): ComparisonType.SYNASTRY}
cross_aspects: Cross-chart aspects indexed by pair {(0,1): (aspects...)}
house_overlays: House overlays indexed by (planet_chart, house_chart)
calculation_timestamp: When this MultiChart was created
metadata: Additional metadata
"""
# Core data
charts: tuple[CalculatedChart, ...]
labels: tuple[str, ...] = ()
# Per-pair relationship types
relationships: dict[tuple[int, int], ComparisonType] = field(default_factory=dict)
# Calculated analysis data
cross_aspects: dict[tuple[int, int], tuple[Aspect, ...]] = field(
default_factory=dict
)
house_overlays: dict[tuple[int, int], tuple[HouseOverlay, ...]] = field(
default_factory=dict
)
# Metadata
calculation_timestamp: datetime = field(
default_factory=lambda: datetime.now(dt.UTC)
)
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Validate chart count and auto-generate labels if needed."""
if len(self.charts) < 2:
raise ValueError("MultiChart requires at least 2 charts")
if len(self.charts) > 4:
raise ValueError("MultiChart supports at most 4 charts")
# Auto-generate labels if not provided
if not self.labels:
default_labels = ("Chart 1", "Chart 2", "Chart 3", "Chart 4")
object.__setattr__(self, "labels", default_labels[: len(self.charts)])
# ===== Indexed Access =====
def __getitem__(self, index: int) -> CalculatedChart:
"""Allow mc[0], mc[1], etc."""
return self.charts[index]
def __len__(self) -> int:
"""Number of charts."""
return len(self.charts)
# ===== Named Properties =====
@property
def chart_count(self) -> int:
"""Number of charts in this MultiChart."""
return len(self.charts)
@property
def chart1(self) -> CalculatedChart:
"""Primary chart (innermost ring)."""
return self.charts[0]
@property
def chart2(self) -> CalculatedChart:
"""Second chart."""
return self.charts[1]
@property
def chart3(self) -> CalculatedChart | None:
"""Third chart (if present)."""
return self.charts[2] if len(self.charts) > 2 else None
@property
def chart4(self) -> CalculatedChart | None:
"""Fourth chart (if present)."""
return self.charts[3] if len(self.charts) > 3 else None
# ===== Semantic Aliases =====
@property
def inner(self) -> CalculatedChart:
"""Semantic alias for innermost chart (chart1)."""
return self.charts[0]
@property
def outer(self) -> CalculatedChart:
"""Semantic alias for outermost chart."""
return self.charts[-1]
@property
def natal(self) -> CalculatedChart:
"""Semantic alias for the natal/base chart (chart1)."""
return self.charts[0]
# ===== Delegate Properties (for visualization compatibility) =====
@property
def location(self):
"""Delegate to chart1's location for visualization compatibility."""
return self.charts[0].location
@property
def datetime(self):
"""Delegate to chart1's datetime for visualization compatibility."""
return self.charts[0].datetime
# ===== Query Methods =====
[docs]
def get_object(self, name: str, chart: int = 0) -> CelestialPosition | None:
"""
Get a celestial object by name from a specific chart.
Args:
name: Object name (e.g., "Sun", "Moon")
chart: Chart index (0-based) or 1-based if > 0
Returns:
CelestialPosition or None
"""
return self.charts[chart].get_object(name)
[docs]
def get_planets(self, chart: int = 0) -> list[CelestialPosition]:
"""Get all planetary objects from specified chart."""
return self.charts[chart].get_planets()
[docs]
def get_angles(self, chart: int = 0) -> list[CelestialPosition]:
"""Get all chart angles from specified chart."""
return self.charts[chart].get_angles()
[docs]
def get_relationship(self, idx1: int, idx2: int) -> ComparisonType | None:
"""
Get the relationship type between two charts.
Args:
idx1: First chart index
idx2: Second chart index
Returns:
ComparisonType or None if not defined
"""
# Normalize key order (smaller first)
key = (min(idx1, idx2), max(idx1, idx2))
return self.relationships.get(key)
[docs]
def get_cross_aspects(
self, chart1_idx: int = 0, chart2_idx: int = 1
) -> tuple[Aspect, ...]:
"""
Get cross-chart aspects between two specific charts.
Args:
chart1_idx: First chart index (default: 0)
chart2_idx: Second chart index (default: 1)
Returns:
Tuple of Aspect objects
"""
# Normalize key order (smaller first)
key = (min(chart1_idx, chart2_idx), max(chart1_idx, chart2_idx))
return self.cross_aspects.get(key, ())
[docs]
def get_all_cross_aspects(self) -> list[Aspect]:
"""
Get all cross-chart aspects flattened into a single list.
Returns:
List of all Aspect objects from all chart pairs
"""
all_aspects = []
for aspects in self.cross_aspects.values():
all_aspects.extend(aspects)
return all_aspects
[docs]
def get_object_aspects(self, object_name: str, chart: int = 0) -> list[Aspect]:
"""
Get all cross-chart aspects involving a specific object from a specific chart.
Args:
object_name: Name of the celestial object (e.g., "Venus", "Moon")
chart: Chart index (0 = inner/natal, 1 = outer/transit, etc.)
Returns:
List of Aspect objects involving the specified object
Example:
>>> venus_aspects = multichart.get_object_aspects("Venus", chart=0)
>>> for asp in venus_aspects:
... print(f"Venus {asp.aspect_name} {asp.object2.name}")
"""
all_aspects = self.get_all_cross_aspects()
result = []
for asp in all_aspects:
# Check if object1 matches (from specified chart)
if asp.object1.name == object_name:
result.append(asp)
# Check if object2 matches (swap perspective)
elif asp.object2.name == object_name:
result.append(asp)
return result
[docs]
def get_house_overlays(
self, planet_chart: int, house_chart: int
) -> tuple[HouseOverlay, ...]:
"""
Get house overlays for a specific chart pair.
Args:
planet_chart: Index of chart whose planets to check
house_chart: Index of chart whose houses to use
Returns:
Tuple of HouseOverlay objects
"""
return self.house_overlays.get((planet_chart, house_chart), ())
[docs]
def get_all_house_overlays(self) -> list[HouseOverlay]:
"""
Get all house overlays flattened into a single list.
Returns:
List of all HouseOverlay objects
"""
all_overlays = []
for overlays in self.house_overlays.values():
all_overlays.extend(overlays)
return all_overlays
# ===== Compatibility Scoring (for synastry) =====
[docs]
def calculate_compatibility_score(
self,
pair: tuple[int, int] = (0, 1),
weights: dict[str, float] | None = None,
) -> float:
"""
Calculate a simple compatibility score based on aspects.
This is a basic implementation - users can implement their own
weighting schemes.
Args:
pair: Which chart pair to score (default: (0, 1))
weights: Optional custom weights for aspect types
Returns:
Compatibility score (0-100)
"""
if weights is None:
weights = {
"Conjunction": 0.5,
"Sextile": 1.0,
"Square": -0.5,
"Trine": 1.0,
"Opposition": -0.3,
}
aspects = self.get_cross_aspects(pair[0], pair[1])
if not aspects:
return 50.0
total_score = 0.0
for aspect in aspects:
weight = weights.get(aspect.aspect_name, 0.0)
orb_strength = 1.0 - (aspect.orb / 10.0)
orb_strength = max(0.0, min(1.0, orb_strength))
total_score += weight * orb_strength
normalized = ((total_score / len(aspects)) + 0.5) / 1.5 * 100
return max(0.0, min(100.0, normalized))
# ===== Visualization =====
[docs]
def draw(self, filename: str = "multichart.svg") -> "ChartDrawBuilder":
"""
Start building a multi-chart visualization.
Args:
filename: Output filename for the SVG
Returns:
ChartDrawBuilder configured for this MultiChart
"""
from stellium.visualization.builder import ChartDrawBuilder
return ChartDrawBuilder(self).with_filename(filename)
# ===== Prompt Text =====
[docs]
def to_prompt_text(
self,
sections: set[str] | None = None,
include_extras: bool = True,
) -> str:
"""
Export multi-chart data as clean, human-readable text for LLM prompts.
Shows each chart clearly labeled, followed by cross-chart aspects
and house overlays.
Args:
sections: Passed through to each individual chart's
``to_prompt_text`` call. ``None`` means all sections.
include_extras: Passed through to each chart. When True,
picks up data from unknown/future components.
Returns:
A multi-line string ready to paste into an LLM prompt.
"""
lines: list[str] = []
# Header
rel_labels: list[str] = []
for (i, j), rel in self.relationships.items():
rel_labels.append(f"{self.labels[i]} / {self.labels[j]}: {rel.value}")
if rel_labels:
lines.append(f"# Multi-Chart ({', '.join(rel_labels)})")
else:
lines.append(f"# Multi-Chart ({self.chart_count} charts)")
lines.append("")
# Each chart
for idx, chart in enumerate(self.charts):
label = self.labels[idx] if idx < len(self.labels) else f"Chart {idx + 1}"
lines.append("---")
lines.append(f"## {label}")
lines.append("")
lines.append(
chart.to_prompt_text(sections=sections, include_extras=include_extras)
)
lines.append("")
# Cross-chart aspects
show_aspects = sections is None or "aspects" in sections
if show_aspects and self.cross_aspects:
lines.append("---")
lines.append("## Cross-Chart Aspects")
lines.append("")
for (i, j), aspects in self.cross_aspects.items():
label_i = self.labels[i] if i < len(self.labels) else f"Chart {i + 1}"
label_j = self.labels[j] if j < len(self.labels) else f"Chart {j + 1}"
if aspects:
lines.append(f"### {label_i} / {label_j}")
lines.append("")
for asp in aspects:
orb = f"{asp.orb:.1f}\u00b0"
if asp.is_applying is True:
state = " (applying)"
elif asp.is_applying is False:
state = " (separating)"
else:
state = ""
lines.append(
f"- [{label_i}] {asp.object1.name} "
f"{asp.aspect_name} "
f"[{label_j}] {asp.object2.name} "
f"\u2014 orb {orb}{state}"
)
lines.append("")
# House overlays
if self.house_overlays:
lines.append("---")
lines.append("## House Overlays")
lines.append("")
for (planet_chart, house_chart), overlays in self.house_overlays.items():
label_p = (
self.labels[planet_chart]
if planet_chart < len(self.labels)
else f"Chart {planet_chart + 1}"
)
label_h = (
self.labels[house_chart]
if house_chart < len(self.labels)
else f"Chart {house_chart + 1}"
)
if overlays:
lines.append(f"### {label_p}'s planets in {label_h}'s houses")
lines.append("")
for overlay in overlays:
lines.append(
f"- {overlay.planet_name} in House {overlay.falls_in_house}"
)
lines.append("")
return "\n".join(lines)
# ===== Serialization =====
[docs]
def to_dict(self) -> dict[str, Any]:
"""
Serialize to dictionary for JSON export.
Returns:
Dictionary with full MultiChart data
"""
# Serialize relationships
relationships_dict = {
f"{k[0]},{k[1]}": v.value for k, v in self.relationships.items()
}
# Serialize cross-aspects
cross_aspects_dict = {}
for (i, j), aspects in self.cross_aspects.items():
key = f"{i},{j}"
cross_aspects_dict[key] = [
{
"object1": asp.object1.name,
"object1_chart": i,
"object2": asp.object2.name,
"object2_chart": j,
"aspect": asp.aspect_name,
"orb": asp.orb,
"is_applying": asp.is_applying,
}
for asp in aspects
]
# Serialize house overlays
house_overlays_dict = {}
for (planet_chart, house_chart), overlays in self.house_overlays.items():
key = f"{planet_chart},{house_chart}"
house_overlays_dict[key] = [
{
"planet": overlay.planet_name,
"planet_chart": planet_chart,
"house": overlay.falls_in_house,
"house_chart": house_chart,
}
for overlay in overlays
]
return {
"chart_count": self.chart_count,
"labels": list(self.labels),
"charts": [chart.to_dict() for chart in self.charts],
"relationships": relationships_dict,
"cross_aspects": cross_aspects_dict,
"house_overlays": house_overlays_dict,
"metadata": self.metadata,
}
# =============================================================================
# MultiChartBuilder
# =============================================================================
[docs]
class MultiChartBuilder:
"""
Fluent builder for creating MultiChart objects.
Supports all multi-chart scenarios:
For synastry:
mc = MultiChartBuilder.synastry(chart1, chart2).calculate()
For transits:
mc = MultiChartBuilder.transit(natal, "2025-06-15").calculate()
For progressions:
mc = MultiChartBuilder.progression(natal, age=30).calculate()
For 3-4 chart configurations:
mc = (MultiChartBuilder.from_chart(natal, "Natal")
.add_progression(age=30, label="Progressed")
.add_transit("2025-06-15", label="Transit")
.calculate())
"""
def __init__(self, charts: list[CalculatedChart] | None = None) -> None:
"""
Initialize builder.
Args:
charts: Optional initial list of charts
"""
self._charts: list[CalculatedChart] = charts or []
self._labels: list[str] = []
self._relationships: dict[tuple[int, int], ComparisonType] = {}
# Aspect configuration
self._cross_aspect_pairs: (
list[tuple[int, int]] | Literal["all", "to_primary", "adjacent"]
) = "to_primary"
self._aspect_engine = None
self._orb_engine: OrbEngine | None = None
self._internal_aspect_engine = None
self._internal_orb_engine: OrbEngine | None = None
# House overlay configuration
self._calculate_house_overlays: bool = True
# Metadata
self._metadata: dict[str, Any] = {}
# ===== Generic Constructors =====
[docs]
@classmethod
def from_charts(
cls,
charts: list[CalculatedChart],
labels: list[str] | None = None,
) -> "MultiChartBuilder":
"""
Create a MultiChartBuilder from a list of calculated charts.
Args:
charts: List of 2-4 CalculatedChart objects
labels: Optional labels for each chart
Returns:
MultiChartBuilder ready for configuration
"""
if len(charts) < 2:
raise ValueError("MultiChart requires at least 2 charts")
if len(charts) > 4:
raise ValueError("MultiChart supports at most 4 charts")
builder = cls(charts)
if labels:
builder._labels = labels
return builder
[docs]
@classmethod
def from_chart(
cls, chart: CalculatedChart, label: str = "Chart 1"
) -> "MultiChartBuilder":
"""
Start building from a single chart.
Use .add_chart(), .add_transit(), etc. to add more charts.
Args:
chart: Initial chart
label: Label for this chart
Returns:
MultiChartBuilder ready for adding more charts
"""
builder = cls([chart])
builder._labels = [label]
return builder
# ===== Convenience Constructors (2-chart) =====
[docs]
@classmethod
def synastry(
cls,
data1: CalculatedChart
| Native
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
data2: CalculatedChart
| Native
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
label1: str = "Person 1",
label2: str = "Person 2",
) -> "MultiChartBuilder":
"""
Create a synastry comparison between two natal charts.
Args:
data1: First person's chart data
data2: Second person's chart data
label1: Label for first person
label2: Label for second person
Returns:
MultiChartBuilder configured for synastry
"""
chart1 = cls._to_chart(data1)
chart2 = cls._to_chart(data2, location_fallback=chart1.location)
builder = cls([chart1, chart2])
builder._labels = [label1, label2]
builder._relationships[(0, 1)] = ComparisonType.SYNASTRY
return builder
[docs]
@classmethod
def transit(
cls,
natal_data: CalculatedChart
| Native
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
transit_data: CalculatedChart
| Native
| dt.datetime
| str
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict | None],
natal_label: str = "Natal",
transit_label: str = "Transit",
) -> "MultiChartBuilder":
"""
Create a transit comparison (natal chart vs current sky).
Args:
natal_data: Natal chart data (CalculatedChart, Native, or tuple)
transit_data: Transit time - can be:
- CalculatedChart: use as-is
- Native: build chart from Native
- datetime or str: use natal chart's location
- tuple[datetime, location]: build chart from tuple
natal_label: Label for natal chart
transit_label: Label for transit chart
Returns:
MultiChartBuilder configured for transits
Example:
# Using a raw datetime (uses natal location)
mc = MultiChartBuilder.transit(natal, datetime(2025, 1, 1, 12, 0))
# Using a tuple with explicit location
mc = MultiChartBuilder.transit(natal, (datetime(2025, 1, 1), "New York"))
"""
natal_chart = cls._to_chart(natal_data)
transit_chart = cls._to_chart(
transit_data, location_fallback=natal_chart.location
)
builder = cls([natal_chart, transit_chart])
builder._labels = [natal_label, transit_label]
builder._relationships[(0, 1)] = ComparisonType.TRANSIT
return builder
[docs]
@classmethod
def progression(
cls,
natal_data: CalculatedChart
| Native
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
progressed_data: CalculatedChart
| Native
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict]
| None = None,
*,
target_date: str | datetime | None = None,
age: float | None = None,
progression_type: Literal["secondary", "tertiary", "minor"] = "secondary",
angle_method: Literal["quotidian", "solar_arc", "naibod"] = "quotidian",
natal_label: str = "Natal",
progressed_label: str = "Progressed",
) -> "MultiChartBuilder":
"""
Create a progression comparison with auto-calculation support.
Supports three progression types:
- **secondary** (default): 1 day = 1 year. The standard progression.
- **tertiary**: 1 day = 1 lunar month (~27.3 days). Faster-moving.
- **minor**: 1 lunar month = 1 year. Intermediate rate.
Args:
natal_data: Natal chart data
progressed_data: Optional pre-calculated progressed chart
target_date: Target date for progression
age: Age in years for progression
progression_type: "secondary" (default), "tertiary", or "minor"
angle_method: How to progress angles
natal_label: Label for natal chart
progressed_label: Label for progressed chart
Returns:
MultiChartBuilder configured for progressions
Examples::
# Secondary (standard, 1 day = 1 year)
prog = MultiChartBuilder.progression(natal, age=30).calculate()
# Tertiary (1 day = 1 lunar month)
prog = MultiChartBuilder.progression(
natal, age=30, progression_type="tertiary"
).calculate()
# Minor (1 lunar month = 1 year)
prog = MultiChartBuilder.progression(
natal, age=30, progression_type="minor"
).calculate()
"""
from stellium.utils.progressions import (
calculate_naibod_arc,
calculate_progressed_datetime,
calculate_solar_arc,
calculate_years_elapsed,
)
natal_chart = cls._to_chart(natal_data)
if progressed_data is not None:
progressed_chart = cls._to_chart(
progressed_data, location_fallback=natal_chart.location
)
else:
natal_datetime = natal_chart.datetime.local_datetime
if age is not None:
target = natal_datetime + timedelta(days=age * 365.25)
elif target_date is not None:
if isinstance(target_date, str):
temp_native = Native(target_date, natal_chart.location)
target = temp_native.datetime.local_datetime
else:
target = target_date
else:
target = datetime.now()
progressed_dt = calculate_progressed_datetime(
natal_datetime, target, progression_type
)
name = natal_chart.metadata.get("name", "Chart")
type_label = (
progression_type.capitalize()
if progression_type != "secondary"
else "Progressed"
)
progressed_chart = ChartBuilder.from_details(
progressed_dt,
natal_chart.location,
name=f"{name} - {type_label}",
).calculate()
if angle_method != "quotidian":
natal_sun = natal_chart.get_object("Sun")
progressed_sun = progressed_chart.get_object("Sun")
if natal_sun and progressed_sun:
if angle_method == "solar_arc":
arc = calculate_solar_arc(
natal_sun.longitude, progressed_sun.longitude
)
elif angle_method == "naibod":
years = calculate_years_elapsed(natal_datetime, target)
arc = calculate_naibod_arc(years)
else:
arc = 0.0
adjusted_positions = []
for pos in progressed_chart.positions:
if pos.object_type == ObjectType.ANGLE:
natal_angle = natal_chart.get_object(pos.name)
if natal_angle:
new_lon = (natal_angle.longitude + arc) % 360
adjusted_positions.append(
replace(pos, longitude=new_lon)
)
else:
adjusted_positions.append(pos)
else:
adjusted_positions.append(pos)
progressed_chart = CalculatedChart(
datetime=progressed_chart.datetime,
location=progressed_chart.location,
positions=tuple(adjusted_positions),
house_systems=progressed_chart.house_systems,
house_placements=progressed_chart.house_placements,
aspects=progressed_chart.aspects,
metadata={
**progressed_chart.metadata,
"progression_type": progression_type,
"angle_method": angle_method,
"angle_arc": arc,
},
)
builder = cls([natal_chart, progressed_chart])
builder._labels = [natal_label, progressed_label]
builder._relationships[(0, 1)] = ComparisonType.PROGRESSION
return builder
[docs]
@classmethod
def arc_direction(
cls,
natal_data: CalculatedChart
| Native
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
*,
target_date: str | datetime | None = None,
age: float | None = None,
arc_type: str = "solar_arc",
rulership_system: Literal["traditional", "modern"] = "traditional",
natal_label: str = "Natal",
directed_label: str = "Directed",
) -> "MultiChartBuilder":
"""
Create an arc direction comparison (natal vs directed chart).
Arc directions move ALL points by the same angular distance.
Args:
natal_data: Natal chart data
target_date: Target date for directions
age: Age in years
arc_type: Type of arc to use
rulership_system: "traditional" or "modern"
natal_label: Label for natal chart
directed_label: Label for directed chart
Returns:
MultiChartBuilder configured for arc directions
"""
from stellium.utils.progressions import (
calculate_lunar_arc,
calculate_naibod_arc,
calculate_planetary_arc,
calculate_progressed_datetime,
calculate_solar_arc,
calculate_years_elapsed,
)
natal_chart = cls._to_chart(natal_data)
natal_datetime = natal_chart.datetime.local_datetime
if age is not None:
target = natal_datetime + timedelta(days=age * 365.25)
elif target_date is not None:
if isinstance(target_date, str):
temp_native = Native(target_date, natal_chart.location)
target = temp_native.datetime.local_datetime
else:
target = target_date
else:
target = datetime.now()
years_elapsed = calculate_years_elapsed(natal_datetime, target)
progressed_dt = calculate_progressed_datetime(natal_datetime, target)
progressed_chart = ChartBuilder.from_details(
progressed_dt, natal_chart.location
).calculate()
natal_positions = {pos.name: pos.longitude for pos in natal_chart.positions}
progressed_positions = {
pos.name: pos.longitude for pos in progressed_chart.positions
}
effective_arc_type = arc_type.lower()
original_arc_type = arc_type
if effective_arc_type == "chart_ruler":
from stellium.utils.chart_ruler import get_chart_ruler
asc = natal_chart.get_object("ASC")
if asc:
ruler_name = get_chart_ruler(asc.sign, rulership_system)
effective_arc_type = ruler_name.lower()
else:
effective_arc_type = "solar_arc"
elif effective_arc_type == "sect":
sun = natal_chart.get_object("Sun")
asc = natal_chart.get_object("ASC")
if sun and asc:
asc_lon = asc.longitude
dsc_lon = (asc_lon + 180) % 360
sun_lon = sun.longitude
if asc_lon < dsc_lon:
is_day = asc_lon <= sun_lon < dsc_lon
else:
is_day = sun_lon >= asc_lon or sun_lon < dsc_lon
effective_arc_type = "solar_arc" if is_day else "lunar"
else:
effective_arc_type = "solar_arc"
if effective_arc_type == "naibod":
arc = calculate_naibod_arc(years_elapsed)
elif effective_arc_type == "solar_arc":
arc = calculate_solar_arc(
natal_positions["Sun"], progressed_positions["Sun"]
)
elif effective_arc_type == "lunar":
arc = calculate_lunar_arc(
natal_positions["Moon"], progressed_positions["Moon"]
)
else:
planet = effective_arc_type.title()
if planet not in natal_positions:
raise ValueError(
f"Unknown arc type or planet not found: '{arc_type}'. "
f"Available: solar_arc, naibod, lunar, chart_ruler, sect, "
f"or a planet name."
)
arc = calculate_planetary_arc(
natal_positions[planet], progressed_positions[planet]
)
directed_positions = []
for pos in natal_chart.positions:
new_longitude = (pos.longitude + arc) % 360
directed_positions.append(replace(pos, longitude=new_longitude))
name = natal_chart.metadata.get("name", "Chart")
directed_chart = CalculatedChart(
datetime=natal_chart.datetime,
location=natal_chart.location,
positions=tuple(directed_positions),
house_systems=natal_chart.house_systems,
house_placements=natal_chart.house_placements,
aspects=(),
metadata={
"arc_type": original_arc_type,
"effective_arc_type": effective_arc_type,
"arc_degrees": arc,
"years_elapsed": years_elapsed,
"target_date": target.isoformat() if target else None,
"name": f"{name} - Directed",
},
)
builder = cls([natal_chart, directed_chart])
builder._labels = [natal_label, directed_label]
builder._relationships[(0, 1)] = ComparisonType.ARC_DIRECTION
return builder
# ===== Adding Charts (for 3-4 chart configs) =====
[docs]
def add_chart(
self,
chart: CalculatedChart,
label: str,
relationship_to: int = 0,
relationship_type: ComparisonType | None = None,
) -> "MultiChartBuilder":
"""
Add a chart to the builder.
Args:
chart: Chart to add
label: Label for this chart
relationship_to: Which existing chart this relates to (default: 0)
relationship_type: Type of relationship (optional)
Returns:
Self for chaining
"""
if len(self._charts) >= 4:
raise ValueError("MultiChart supports at most 4 charts")
new_idx = len(self._charts)
self._charts.append(chart)
self._labels.append(label)
if relationship_type is not None:
key = (min(relationship_to, new_idx), max(relationship_to, new_idx))
self._relationships[key] = relationship_type
return self
[docs]
def add_transit(
self,
transit_data: str | datetime | CalculatedChart,
location: Any = None,
label: str = "Transit",
) -> "MultiChartBuilder":
"""
Add a transit chart.
Args:
transit_data: Transit datetime or chart
location: Location (uses chart[0] location if None)
label: Label for transit chart
Returns:
Self for chaining
"""
if not self._charts:
raise ValueError("Must have at least one chart before adding transit")
if isinstance(transit_data, CalculatedChart):
transit_chart = transit_data
else:
loc = location or self._charts[0].location
transit_chart = self._to_chart((transit_data, loc))
return self.add_chart(
transit_chart,
label,
relationship_to=0,
relationship_type=ComparisonType.TRANSIT,
)
[docs]
def add_progression(
self,
*,
target_date: str | datetime | None = None,
age: float | None = None,
progression_type: Literal["secondary", "tertiary", "minor"] = "secondary",
angle_method: Literal["quotidian", "solar_arc", "naibod"] = "quotidian",
label: str = "Progressed",
) -> "MultiChartBuilder":
"""
Add a progressed chart.
Args:
target_date: Target date for progression
age: Age in years
progression_type: "secondary" (default), "tertiary", or "minor"
angle_method: How to progress angles
label: Label for progressed chart
Returns:
Self for chaining
"""
if not self._charts:
raise ValueError("Must have at least one chart before adding progression")
# Build temporary progression to get the chart
temp_builder = MultiChartBuilder.progression(
self._charts[0],
target_date=target_date,
age=age,
progression_type=progression_type,
angle_method=angle_method,
)
progressed_chart = temp_builder._charts[1]
return self.add_chart(
progressed_chart,
label,
relationship_to=0,
relationship_type=ComparisonType.PROGRESSION,
)
[docs]
def add_arc_direction(
self,
*,
target_date: str | datetime | None = None,
age: float | None = None,
arc_type: str = "solar_arc",
rulership_system: Literal["traditional", "modern"] = "traditional",
label: str = "Directed",
) -> "MultiChartBuilder":
"""
Add a directed chart.
Args:
target_date: Target date for directions
age: Age in years
arc_type: Type of arc to use
rulership_system: Rulership system
label: Label for directed chart
Returns:
Self for chaining
"""
if not self._charts:
raise ValueError("Must have at least one chart before adding direction")
temp_builder = MultiChartBuilder.arc_direction(
self._charts[0],
target_date=target_date,
age=age,
arc_type=arc_type,
rulership_system=rulership_system,
)
directed_chart = temp_builder._charts[1]
return self.add_chart(
directed_chart,
label,
relationship_to=0,
relationship_type=ComparisonType.ARC_DIRECTION,
)
# ===== Configuration Methods =====
[docs]
def with_labels(self, labels: list[str]) -> "MultiChartBuilder":
"""
Set labels for each chart.
Args:
labels: List of labels
Returns:
Self for chaining
"""
self._labels = labels
return self
[docs]
def with_cross_aspects(
self,
pairs: list[tuple[int, int]]
| Literal["all", "to_primary", "adjacent"] = "to_primary",
) -> "MultiChartBuilder":
"""
Configure which chart pairs to calculate cross-aspects for.
Args:
pairs: Either:
- "to_primary": Only aspects to chart[0] (default)
- "adjacent": Adjacent pairs (0-1, 1-2, 2-3)
- "all": All possible pairs
- List of (i, j) tuples for explicit pairs
Returns:
Self for chaining
"""
self._cross_aspect_pairs = pairs
return self
[docs]
def without_cross_aspects(self) -> "MultiChartBuilder":
"""
Disable cross-aspect calculation.
Returns:
Self for chaining
"""
self._cross_aspect_pairs = []
return self
[docs]
def with_house_overlays(self, enabled: bool = True) -> "MultiChartBuilder":
"""
Enable or disable house overlay calculation.
Args:
enabled: Whether to calculate house overlays
Returns:
Self for chaining
"""
self._calculate_house_overlays = enabled
return self
[docs]
def without_house_overlays(self) -> "MultiChartBuilder":
"""
Disable house overlay calculation.
Returns:
Self for chaining
"""
self._calculate_house_overlays = False
return self
[docs]
def with_aspect_engine(self, engine) -> "MultiChartBuilder":
"""
Set the aspect engine for cross-chart aspects.
Args:
engine: AspectEngine instance
Returns:
Self for chaining
"""
self._aspect_engine = engine
return self
[docs]
def with_orb_engine(self, engine: OrbEngine) -> "MultiChartBuilder":
"""
Set the orb engine for cross-chart aspects.
Args:
engine: OrbEngine instance
Returns:
Self for chaining
"""
self._orb_engine = engine
return self
[docs]
def with_internal_aspect_engine(self, engine) -> "MultiChartBuilder":
"""
Set aspect engine for calculating internal (natal) aspects.
Args:
engine: AspectEngine instance
Returns:
Self for chaining
"""
self._internal_aspect_engine = engine
return self
[docs]
def with_internal_orb_engine(self, engine: OrbEngine) -> "MultiChartBuilder":
"""
Set orb engine for calculating internal (natal) aspects.
Args:
engine: OrbEngine instance
Returns:
Self for chaining
"""
self._internal_orb_engine = engine
return self
# ===== Build =====
[docs]
def calculate(self) -> MultiChart:
"""
Execute all calculations and return the MultiChart.
Returns:
MultiChart object with all calculated data
"""
if len(self._charts) < 2:
raise ValueError("Must have at least 2 charts to create MultiChart")
# Ensure all charts have internal aspects
charts_with_aspects = []
for chart in self._charts:
if not chart.aspects:
chart = self._ensure_internal_aspects(chart)
charts_with_aspects.append(chart)
# Determine which pairs to calculate aspects for
pairs = self._resolve_aspect_pairs()
# Calculate cross-aspects
cross_aspects: dict[tuple[int, int], tuple[Aspect, ...]] = {}
if pairs:
for i, j in pairs:
aspects = self._calculate_cross_aspects(
charts_with_aspects[i], charts_with_aspects[j], i, j
)
if aspects:
cross_aspects[(i, j)] = tuple(aspects)
# Calculate house overlays
house_overlays: dict[tuple[int, int], tuple[HouseOverlay, ...]] = {}
if self._calculate_house_overlays:
# Calculate overlays to primary only (same default as aspects)
for i in range(1, len(charts_with_aspects)):
# Chart[i] planets in chart[0] houses
overlays_i_in_0 = self._calculate_house_overlays_for_pair(
charts_with_aspects[i], charts_with_aspects[0], i, 0
)
if overlays_i_in_0:
house_overlays[(i, 0)] = tuple(overlays_i_in_0)
# Chart[0] planets in chart[i] houses
overlays_0_in_i = self._calculate_house_overlays_for_pair(
charts_with_aspects[0], charts_with_aspects[i], 0, i
)
if overlays_0_in_i:
house_overlays[(0, i)] = tuple(overlays_0_in_i)
return MultiChart(
charts=tuple(charts_with_aspects),
labels=tuple(self._labels) if self._labels else (),
relationships=self._relationships,
cross_aspects=cross_aspects,
house_overlays=house_overlays,
metadata=self._metadata,
)
# ===== Private Helper Methods =====
@staticmethod
def _to_chart(
data: CalculatedChart
| Native
| dt.datetime
| str
| tuple[str | dt.datetime | dict, str | tuple[float, float] | dict | None],
location_fallback: Any = None,
) -> CalculatedChart:
"""Convert various input types to CalculatedChart.
Args:
data: Input data - can be:
- CalculatedChart: returned as-is
- Native: builds chart from Native
- datetime or str: builds chart using location_fallback
- tuple[datetime, location]: builds chart from tuple
location_fallback: Location to use when only datetime is provided
Returns:
CalculatedChart instance
"""
if isinstance(data, CalculatedChart):
return data
elif isinstance(data, Native):
return ChartBuilder.from_native(data).calculate()
elif isinstance(data, dt.datetime | str) and not isinstance(data, tuple):
# Handle raw datetime or date string - use location fallback
if location_fallback is None:
raise ValueError(
"Location fallback required when passing datetime without location. "
"Either pass a tuple (datetime, location) or ensure a fallback is available."
)
native = Native(data, location_fallback)
return ChartBuilder.from_native(native).calculate()
elif isinstance(data, tuple) and len(data) == 2:
datetime_input, location_input = data
if location_input is None:
if location_fallback is None:
raise ValueError("Location cannot be None without fallback")
location_input = location_fallback
native = Native(datetime_input, location_input)
return ChartBuilder.from_native(native).calculate()
else:
raise TypeError(f"Invalid data type: {type(data)}")
def _resolve_aspect_pairs(self) -> list[tuple[int, int]]:
"""Resolve which chart pairs to calculate aspects for."""
n = len(self._charts)
if isinstance(self._cross_aspect_pairs, list):
return self._cross_aspect_pairs
elif self._cross_aspect_pairs == "to_primary":
return [(0, i) for i in range(1, n)]
elif self._cross_aspect_pairs == "adjacent":
return [(i, i + 1) for i in range(n - 1)]
elif self._cross_aspect_pairs == "all":
pairs = []
for i in range(n):
for j in range(i + 1, n):
pairs.append((i, j))
return pairs
else:
return []
def _get_orb_engine_for_pair(self, i: int, j: int) -> OrbEngine:
"""Get orb engine for a specific chart pair."""
from stellium.engines.orbs import SimpleOrbEngine
if self._orb_engine:
return self._orb_engine
# Determine default orbs based on relationship type
key = (min(i, j), max(i, j))
relationship = self._relationships.get(key)
if relationship == ComparisonType.SYNASTRY:
orb_map = {
"Conjunction": 6.0,
"Sextile": 4.0,
"Square": 6.0,
"Trine": 6.0,
"Opposition": 6.0,
}
elif relationship == ComparisonType.TRANSIT:
orb_map = {
"Conjunction": 3.0,
"Sextile": 2.0,
"Square": 3.0,
"Trine": 3.0,
"Opposition": 3.0,
}
elif relationship in (ComparisonType.PROGRESSION, ComparisonType.ARC_DIRECTION):
orb_map = {
"Conjunction": 1.0,
"Sextile": 1.0,
"Square": 1.0,
"Trine": 1.0,
"Opposition": 1.0,
}
else:
orb_map = {
"Conjunction": 6.0,
"Sextile": 4.0,
"Square": 6.0,
"Trine": 6.0,
"Opposition": 6.0,
}
return SimpleOrbEngine(orb_map=orb_map)
def _ensure_internal_aspects(self, chart: CalculatedChart) -> CalculatedChart:
"""Ensure a chart has internal aspects calculated."""
from stellium.engines.aspects import ModernAspectEngine
from stellium.engines.orbs import SimpleOrbEngine
aspect_engine = self._internal_aspect_engine or ModernAspectEngine()
orb_engine = self._internal_orb_engine or SimpleOrbEngine()
internal_aspects = aspect_engine.calculate_aspects(
list(chart.positions), orb_engine
)
return CalculatedChart(
datetime=chart.datetime,
location=chart.location,
positions=chart.positions,
house_systems=chart.house_systems,
house_placements=chart.house_placements,
aspects=tuple(internal_aspects),
metadata=chart.metadata,
)
def _calculate_cross_aspects(
self,
chart1: CalculatedChart,
chart2: CalculatedChart,
idx1: int,
idx2: int,
) -> list[Aspect]:
"""Calculate cross-chart aspects between two charts."""
from stellium.engines.aspects import CrossChartAspectEngine
engine = self._aspect_engine or CrossChartAspectEngine()
orb_engine = self._get_orb_engine_for_pair(idx1, idx2)
return engine.calculate_cross_aspects(
list(chart1.positions),
list(chart2.positions),
orb_engine,
)
def _calculate_house_overlays_for_pair(
self,
planet_chart: CalculatedChart,
house_chart: CalculatedChart,
planet_idx: int,
house_idx: int,
) -> list[HouseOverlay]:
"""Calculate house overlays for one chart's planets in another's houses."""
from stellium.utils.houses import find_house_for_longitude
overlays = []
try:
house_cusps = house_chart.get_houses().cusps
except (ValueError, KeyError):
return overlays
for pos in planet_chart.positions:
house_num = find_house_for_longitude(pos.longitude, house_cusps)
overlay = HouseOverlay(
planet_name=pos.name,
planet_owner=f"chart{planet_idx + 1}",
falls_in_house=house_num,
house_owner=f"chart{house_idx + 1}",
planet_position=pos,
)
overlays.append(overlay)
return overlays