"""
Synthesis charts: Composite and Davison chart calculations.
These create a single "synthesized" chart from two source charts,
representing a relationship or combined energy.
Composite: Midpoint of each planet/point between charts
Davison: Midpoint in time and space, then regular chart calculation
"""
from __future__ import annotations
import datetime as dt
from dataclasses import dataclass
from math import atan2, cos, degrees, floor, radians, sin, sqrt
from typing import TYPE_CHECKING, Any
import pytz
import swisseph as swe
from stellium.core.models import (
CalculatedChart,
ChartDateTime,
ChartLocation,
)
if TYPE_CHECKING:
from stellium.core.native import Native
# =============================================================================
# Helper Functions
# =============================================================================
[docs]
def calculate_midpoint_longitude(
lon1: float, lon2: float, method: str = "short_arc"
) -> float:
"""
Calculate midpoint between two zodiac longitudes.
Args:
lon1: First longitude (0-360)
lon2: Second longitude (0-360)
method: "short_arc" (default) or "long_arc"
Returns:
Midpoint longitude (0-360)
Examples:
>>> calculate_midpoint_longitude(10, 20) # Both in Aries
15.0
>>> calculate_midpoint_longitude(10, 190) # Aries and Libra
100.0 # Cancer (short arc)
>>> calculate_midpoint_longitude(10, 190, "long_arc")
280.0 # Capricorn (long arc)
"""
if method == "short_arc":
# Find shorter path around circle
diff = (lon2 - lon1 + 360) % 360
if diff > 180:
diff = diff - 360
return (lon1 + diff / 2) % 360
elif method == "long_arc":
# Find longer path around circle
diff = (lon2 - lon1 + 360) % 360
if diff <= 180:
diff = diff - 360
return (lon1 + diff / 2) % 360
else:
raise ValueError(f"Unknown midpoint method: {method}")
[docs]
def julian_day_to_datetime(jd: float, timezone: str = "UTC") -> dt.datetime:
"""
Convert Julian day to Python datetime.
Args:
jd: Julian day number
timezone: Timezone string (default UTC)
Returns:
Timezone-aware datetime object
"""
# swe.revjul returns (year, month, day, hour_as_float)
year, month, day, h_float = swe.revjul(jd)
# Extract hours, minutes, seconds from the float hour
hour = floor(h_float)
h_float = (h_float - hour) * 60
minute = floor(h_float)
h_float = (h_float - minute) * 60
second = int(round(h_float))
# Handle edge cases where rounding pushes values over
if second == 60:
minute += 1
second = 0
if minute == 60:
hour += 1
minute = 0
if hour == 24:
# Need to advance the day - let datetime handle it
base_dt = dt.datetime(year, month, day, 0, 0, 0)
base_dt = base_dt + dt.timedelta(days=1)
year, month, day = base_dt.year, base_dt.month, base_dt.day
hour = 0
# Create datetime and localize to UTC (Julian days are in UT)
utc_dt = dt.datetime(year, month, day, hour, minute, second, tzinfo=pytz.UTC)
return utc_dt
[docs]
def calculate_datetime_midpoint(
dt1: ChartDateTime, dt2: ChartDateTime
) -> tuple[dt.datetime, float]:
"""
Calculate midpoint between two datetimes using Julian day.
Args:
dt1: First chart datetime
dt2: Second chart datetime
Returns:
Tuple of (midpoint_datetime, midpoint_julian_day)
"""
# Average the Julian days - this handles all calendar complexity!
jd_mid = (dt1.julian_day + dt2.julian_day) / 2
# Convert back to datetime
mid_datetime = julian_day_to_datetime(jd_mid)
return mid_datetime, jd_mid
[docs]
def calculate_location_midpoint(
loc1: ChartLocation, loc2: ChartLocation, method: str = "great_circle"
) -> ChartLocation:
"""
Calculate geographic midpoint between two locations.
Args:
loc1: First location
loc2: Second location
method: "great_circle" (default, geodesic) or "simple" (arithmetic mean)
Returns:
Midpoint location
Note:
Great circle (geodesic) midpoint follows the Earth's curvature and is
more accurate for locations far apart. Simple arithmetic mean can give
incorrect results, especially across the date line or for distant points.
"""
if method == "simple":
# Simple arithmetic mean - fast but inaccurate for distant points
mid_lat = (loc1.latitude + loc2.latitude) / 2
mid_lon = (loc1.longitude + loc2.longitude) / 2
elif method == "great_circle":
# Great circle midpoint using spherical geometry
# Formula from: https://www.movable-type.co.uk/scripts/latlong.html
# Convert to radians
lat1 = radians(loc1.latitude)
lat2 = radians(loc2.latitude)
lon1 = radians(loc1.longitude)
lon2 = radians(loc2.longitude)
# Difference in longitude
d_lon = lon2 - lon1
# Calculate midpoint using vector math on unit sphere
# Convert both points to Cartesian, average, convert back
bx = cos(lat2) * cos(d_lon)
by = cos(lat2) * sin(d_lon)
mid_lat = atan2(sin(lat1) + sin(lat2), sqrt((cos(lat1) + bx) ** 2 + by**2))
mid_lon = lon1 + atan2(by, cos(lat1) + bx)
# Convert back to degrees
mid_lat = degrees(mid_lat)
mid_lon = degrees(mid_lon)
# Normalize longitude to -180 to 180
mid_lon = ((mid_lon + 180) % 360) - 180
else:
raise ValueError(f"Unknown location midpoint method: {method}")
# Create location name from both sources
name1 = loc1.name or "Location 1"
name2 = loc2.name or "Location 2"
mid_name = f"Midpoint: {name1} / {name2}"
# For timezone, we'll leave it empty - the Native class will handle it
# based on the coordinates when we create the chart
return ChartLocation(
latitude=mid_lat,
longitude=mid_lon,
name=mid_name,
timezone="",
)
# =============================================================================
# SynthesisChart Data Model
# =============================================================================
[docs]
@dataclass(frozen=True)
class SynthesisChart(CalculatedChart):
"""
A chart synthesized from two source charts (composite or davison).
Inherits all fields from CalculatedChart:
- positions: tuple[CelestialPosition, ...]
- aspects: tuple[Aspect, ...]
- house_systems: dict[str, HouseCusps]
- house_placements: dict[str, dict]
- datetime: ChartDateTime
- location: ChartLocation
- metadata: dict
And adds synthesis-specific fields.
"""
# === Core Synthesis Metadata ===
synthesis_method: str = ""
"""The synthesis method used: "composite" or "davison" """
source_chart1: CalculatedChart | None = None
"""The first source chart (full chart object for reference)"""
source_chart2: CalculatedChart | None = None
"""The second source chart (full chart object for reference)"""
chart1_label: str = "Chart 1"
"""Descriptive label for first chart (e.g., "Alice", "Natal")"""
chart2_label: str = "Chart 2"
"""Descriptive label for second chart (e.g., "Bob", "Transit")"""
# === Method-Specific Configuration ===
midpoint_method: str | None = None
"""Composite only: "short_arc" or "long_arc" """
houses_config: bool | str | None = None
"""Composite only: True (derived), False (none), or "place" """
location_method: str | None = None
"""Davison only: "simple" or "great_circle" """
def _section_info(self, lines: list[str]) -> None:
"""Override to include synthesis metadata in the header."""
method_label = (
self.synthesis_method.title() if self.synthesis_method else "Synthesis"
)
lines.append(
f"# {method_label} Chart: {self.chart1_label} + {self.chart2_label}"
)
lines.append("")
lines.append(f"*Method: {self.synthesis_method}*")
if self.synthesis_method == "composite" and self.midpoint_method:
lines.append(f"*Midpoint method: {self.midpoint_method}*")
if self.synthesis_method == "davison" and self.location_method:
lines.append(f"*Location method: {self.location_method}*")
# Datetime / location of the synthesis chart itself
if self.datetime:
if self.datetime.local_datetime:
dt_str = self.datetime.local_datetime.strftime("%B %d, %Y at %I:%M %p")
else:
dt_str = self.datetime.utc_datetime.strftime("%B %d, %Y at %H:%M UTC")
lines.append(f"*Synthesis date: {dt_str}*")
if self.location:
loc_name = getattr(self.location, "name", None)
if loc_name:
lines.append(f"*Location: {loc_name}*")
lines.append("")
[docs]
def to_prompt_text(
self,
sections: set[str] | None = None,
include_extras: bool = True,
include_source_charts: bool = False,
) -> str:
"""
Export synthesis chart as prompt text.
Args:
sections: Section names to include (None = all available).
include_extras: Pick up unknown/future component data (default True).
include_source_charts: If True, also include the full prompt
text of both source charts for reference.
Returns:
A multi-line string ready to paste into an LLM prompt.
"""
text = super().to_prompt_text(sections=sections, include_extras=include_extras)
if include_source_charts:
parts = [text]
if self.source_chart1:
parts.append("")
parts.append("---")
parts.append(f"## Source: {self.chart1_label}")
parts.append("")
parts.append(
self.source_chart1.to_prompt_text(
sections=sections,
include_extras=include_extras,
)
)
if self.source_chart2:
parts.append("")
parts.append("---")
parts.append(f"## Source: {self.chart2_label}")
parts.append("")
parts.append(
self.source_chart2.to_prompt_text(
sections=sections,
include_extras=include_extras,
)
)
text = "\n".join(parts)
return text
[docs]
def to_dict(self) -> dict[str, Any]:
"""Extend parent's to_dict with synthesis-specific fields."""
base_dict = super().to_dict()
# Add synthesis metadata
base_dict["synthesis"] = {
"method": self.synthesis_method,
"chart1_label": self.chart1_label,
"chart2_label": self.chart2_label,
}
# Add method-specific config
if self.synthesis_method == "composite":
base_dict["synthesis"]["midpoint_method"] = self.midpoint_method
base_dict["synthesis"]["houses"] = self.houses_config
elif self.synthesis_method == "davison":
base_dict["synthesis"]["location_method"] = self.location_method
return base_dict
# =============================================================================
# SynthesisBuilder
# =============================================================================
[docs]
class SynthesisBuilder:
"""
Builder for synthesizing two charts into one (composite or davison).
Example::
# Simple davison
davison = SynthesisBuilder.davison(chart1, chart2).calculate()
# Configured composite
composite = (SynthesisBuilder.composite(chart1, chart2)
.with_midpoint_method("short_arc")
.with_labels("Alice", "Bob")
.calculate())
"""
def __init__(
self,
chart1: CalculatedChart | Native,
chart2: CalculatedChart | Native,
method: str,
):
"""Internal constructor. Use .composite() or .davison() instead."""
self._chart1 = chart1
self._chart2 = chart2
self._method = method
# Configuration (with defaults)
self._midpoint_method = "short_arc" # Composite: "short_arc" or "long_arc"
self._houses: bool | str = (
True # Composite: True (derived), False (none), "place"
)
self._location_method = "great_circle" # Davison: "great_circle" or "simple"
self._chart1_label = "Chart 1"
self._chart2_label = "Chart 2"
# --- Constructors ---
[docs]
@classmethod
def composite(
cls, chart1: CalculatedChart | Native, chart2: CalculatedChart | Native
) -> SynthesisBuilder:
"""
Create composite chart (midpoint of all positions).
Args:
chart1: First chart (CalculatedChart or Native)
chart2: Second chart (CalculatedChart or Native)
Returns:
SynthesisBuilder configured for composite calculation
"""
return cls(chart1, chart2, method="composite")
[docs]
@classmethod
def davison(
cls, chart1: CalculatedChart | Native, chart2: CalculatedChart | Native
) -> SynthesisBuilder:
"""
Create davison chart (midpoint in time and space).
Args:
chart1: First chart (CalculatedChart or Native)
chart2: Second chart (CalculatedChart or Native)
Returns:
SynthesisBuilder configured for davison calculation
"""
return cls(chart1, chart2, method="davison")
# --- Configuration Methods ---
[docs]
def with_midpoint_method(self, method: str) -> SynthesisBuilder:
"""
Set midpoint calculation method for composite charts.
Args:
method: "short_arc" (default) or "long_arc"
- short_arc: Always takes shorter path around zodiac
- long_arc: Always takes longer path
Returns:
Self for chaining
"""
self._midpoint_method = method
return self
[docs]
def with_houses(self, houses: bool | str) -> SynthesisBuilder:
"""
Set house calculation method for composite charts.
Args:
houses: True (default) - Derived ASC method (midpoint Ascendants, derive cusps)
False - No houses (positions only)
"place" - Reference place method (geographic midpoint + derived time)
Returns:
Self for chaining
Example:
# No houses
composite = SynthesisBuilder.composite(c1, c2).with_houses(False).calculate()
# Reference place method
composite = SynthesisBuilder.composite(c1, c2).with_houses("place").calculate()
"""
self._houses = houses
return self
[docs]
def with_location_method(self, method: str) -> SynthesisBuilder:
"""
Set location midpoint method for davison charts.
Args:
method: "great_circle" (default) - Geodesic midpoint following Earth's curvature
"simple" - Arithmetic mean of lat/lon (faster but less accurate)
Returns:
Self for chaining
"""
self._location_method = method
return self
[docs]
def with_labels(self, label1: str, label2: str) -> SynthesisBuilder:
"""
Set descriptive labels for source charts.
Args:
label1: Label for first chart (e.g., "Alice", "Natal")
label2: Label for second chart (e.g., "Bob", "Transit")
Returns:
Self for chaining
"""
self._chart1_label = label1
self._chart2_label = label2
return self
# --- Calculation ---
[docs]
def calculate(self) -> SynthesisChart:
"""
Calculate the synthesis chart.
Returns:
SynthesisChart (subclass of CalculatedChart)
"""
# Ensure we have CalculatedChart objects
chart1 = self._ensure_calculated(self._chart1)
chart2 = self._ensure_calculated(self._chart2)
if self._method == "composite":
return self._calculate_composite(chart1, chart2)
elif self._method == "davison":
return self._calculate_davison(chart1, chart2)
else:
raise ValueError(f"Unknown synthesis method: {self._method}")
# --- Internal Helpers ---
def _ensure_calculated(
self, chart_or_native: CalculatedChart | Native
) -> CalculatedChart:
"""Convert Native to CalculatedChart if needed."""
from stellium.core.builder import ChartBuilder
from stellium.core.native import Native
if isinstance(chart_or_native, Native):
return ChartBuilder.from_native(chart_or_native).calculate()
return chart_or_native
def _calculate_davison(
self, chart1: CalculatedChart, chart2: CalculatedChart
) -> SynthesisChart:
"""
Calculate davison chart using time/space midpoint.
Algorithm:
1. Calculate midpoint datetime (average Julian day)
2. Calculate midpoint location (simple or great_circle)
3. Create Native with midpoint datetime/location
4. Use ChartBuilder to calculate chart normally
5. Wrap result in SynthesisChart with source chart references
"""
from stellium.core.builder import ChartBuilder
from stellium.core.native import Native
# 1. Calculate datetime midpoint
mid_datetime, mid_jd = calculate_datetime_midpoint(
chart1.datetime, chart2.datetime
)
# 2. Calculate location midpoint
mid_location = calculate_location_midpoint(
chart1.location, chart2.location, method=self._location_method
)
# 3. Create Native with midpoint coordinates
# Native will handle timezone lookup from coordinates
native = Native(
datetime_input=mid_datetime,
location_input=(mid_location.latitude, mid_location.longitude),
)
# 4. Calculate chart normally using ChartBuilder
# Include aspects so they appear in the davison chart visualization
base_chart = ChartBuilder.from_native(native).with_aspects().calculate()
# 5. Wrap in SynthesisChart with full metadata
return SynthesisChart(
# From base chart calculation
datetime=base_chart.datetime,
location=base_chart.location,
positions=base_chart.positions,
house_systems=base_chart.house_systems,
house_placements=base_chart.house_placements,
aspects=base_chart.aspects,
metadata=base_chart.metadata,
calculation_timestamp=base_chart.calculation_timestamp,
# Synthesis-specific
synthesis_method="davison",
source_chart1=chart1,
source_chart2=chart2,
chart1_label=self._chart1_label,
chart2_label=self._chart2_label,
location_method=self._location_method,
)
def _calculate_composite(
self, chart1: CalculatedChart, chart2: CalculatedChart
) -> SynthesisChart:
"""
Calculate composite chart using midpoint method.
Algorithm:
1. For each planet in chart1, find corresponding planet in chart2
2. Calculate midpoint longitude (respecting midpoint_method)
3. Create new CelestialPosition with midpoint coordinates
4. Calculate houses based on _houses setting:
- True: Derived ASC method (midpoint Ascendants)
- False: No houses
- "place": Reference place method (geographic midpoint)
5. Calculate aspects between composite positions
6. Return SynthesisChart with all data
"""
from stellium.core.builder import ChartBuilder
from stellium.core.models import CelestialPosition
from stellium.core.native import Native
from stellium.engines.aspects import ModernAspectEngine
# 1. Calculate composite positions (midpoint of each planet/point)
composite_positions: list[CelestialPosition] = []
for pos1 in chart1.positions:
# Find matching position in chart2
pos2 = chart2.get_object(pos1.name)
if pos2 is None:
continue # Skip if not found in both charts
# Calculate midpoint longitude
mid_lon = calculate_midpoint_longitude(
pos1.longitude, pos2.longitude, method=self._midpoint_method
)
# For latitude, use simple average (latitude midpoints don't wrap)
mid_lat = (pos1.latitude + pos2.latitude) / 2
# For distance, use average
mid_dist = (pos1.distance + pos2.distance) / 2
# For speed, use average (affects retrograde detection)
mid_speed_lon = (pos1.speed_longitude + pos2.speed_longitude) / 2
mid_speed_lat = (pos1.speed_latitude + pos2.speed_latitude) / 2
mid_speed_dist = (pos1.speed_distance + pos2.speed_distance) / 2
# Create composite position
composite_pos = CelestialPosition(
name=pos1.name,
object_type=pos1.object_type,
longitude=mid_lon,
latitude=mid_lat,
distance=mid_dist,
speed_longitude=mid_speed_lon,
speed_latitude=mid_speed_lat,
speed_distance=mid_speed_dist,
phase=None, # Composite charts don't have meaningful phase data
)
composite_positions.append(composite_pos)
# 2. Calculate houses based on configuration
house_systems: dict = {}
house_placements: dict = {}
composite_datetime = None
composite_location = None
if self._houses is False:
# No houses - just use placeholder datetime/location from chart1
composite_datetime = chart1.datetime
composite_location = chart1.location
elif self._houses is True:
# Derived ASC method
# Use midpoint datetime and midpoint location to calculate houses
# The ASC will naturally be the midpoint of the two ASCs
mid_datetime, _ = calculate_datetime_midpoint(
chart1.datetime, chart2.datetime
)
mid_location = calculate_location_midpoint(
chart1.location, chart2.location, method="great_circle"
)
# Create native and calculate just for houses
native = Native(
datetime_input=mid_datetime,
location_input=(mid_location.latitude, mid_location.longitude),
)
temp_chart = ChartBuilder.from_native(native).calculate()
house_systems = temp_chart.house_systems
house_placements = self._calculate_house_placements(
composite_positions, house_systems
)
composite_datetime = temp_chart.datetime
composite_location = temp_chart.location
elif self._houses == "place":
# Reference place method
# Use geographic midpoint with a derived time
mid_location = calculate_location_midpoint(
chart1.location, chart2.location, method="great_circle"
)
mid_datetime, _ = calculate_datetime_midpoint(
chart1.datetime, chart2.datetime
)
native = Native(
datetime_input=mid_datetime,
location_input=(mid_location.latitude, mid_location.longitude),
)
temp_chart = ChartBuilder.from_native(native).calculate()
house_systems = temp_chart.house_systems
house_placements = self._calculate_house_placements(
composite_positions, house_systems
)
composite_datetime = temp_chart.datetime
composite_location = temp_chart.location
# 3. Calculate aspects between composite positions
from stellium.engines.orbs import SimpleOrbEngine
aspect_engine = ModernAspectEngine()
orb_engine = SimpleOrbEngine()
aspects = aspect_engine.calculate_aspects(composite_positions, orb_engine)
# 4. Create and return SynthesisChart
return SynthesisChart(
datetime=composite_datetime,
location=composite_location,
positions=tuple(composite_positions),
house_systems=house_systems,
house_placements=house_placements,
aspects=tuple(aspects),
metadata={},
# Synthesis-specific
synthesis_method="composite",
source_chart1=chart1,
source_chart2=chart2,
chart1_label=self._chart1_label,
chart2_label=self._chart2_label,
midpoint_method=self._midpoint_method,
houses_config=self._houses,
)
def _calculate_house_placements(
self,
positions: list,
house_systems: dict,
) -> dict:
"""Calculate which house each position falls in for each house system."""
from stellium.utils.houses import find_house_for_longitude
placements: dict = {}
for system_name, house_cusps in house_systems.items():
placements[system_name] = {}
for pos in positions:
house = find_house_for_longitude(pos.longitude, house_cusps.cusps)
placements[system_name][pos.name] = house
return placements