"""
Chart shape detection utilities.
Identifies the overall pattern/distribution of planets in a chart.
Classic chart shapes include: Bundle, Bowl, Bucket, Locomotive, Seesaw, Splay, and Splash.
"""
from typing import Literal
from stellium.core.models import CalculatedChart, CelestialPosition, ObjectType
ChartShape = Literal[
"Bundle",
"Bowl",
"Bucket",
"Locomotive",
"Seesaw",
"Splay",
"Splash",
]
[docs]
def detect_chart_shape(chart: CalculatedChart) -> tuple[ChartShape, dict]:
"""
Detect the overall shape/pattern of planets in a chart.
Args:
chart: Calculated chart
Returns:
Tuple of (shape_name, metadata_dict) where metadata contains
additional info like span, leading planet, handle planet, etc.
"""
# Get only planets for shape detection (not angles, asteroids, or points)
planets = [
p
for p in chart.positions
if p.object_type == ObjectType.PLANET and p.name != "Earth"
]
if len(planets) < 3:
return "Splash", {} # Not enough planets to determine shape
# Sort planets by longitude
sorted_planets = sorted(planets, key=lambda p: p.longitude)
# Calculate gaps between consecutive planets
gaps = []
for i in range(len(sorted_planets)):
current = sorted_planets[i]
next_planet = sorted_planets[(i + 1) % len(sorted_planets)]
# Calculate gap (handling 360° wrap)
gap = (next_planet.longitude - current.longitude) % 360
gaps.append(
{
"from": current,
"to": next_planet,
"degrees": gap,
}
)
# Calculate total span (from first to last planet in sorted order)
span = _calculate_span(sorted_planets)
largest_gap = max(gaps, key=lambda g: g["degrees"])
# Detection logic (order matters!)
# 1. Bundle: All planets within 120°
if span <= 120:
return "Bundle", {
"span": span,
"leading_planet": sorted_planets[0].name,
}
# 2. Bowl: All planets within 180° (half the chart)
if span <= 180:
return "Bowl", {
"span": span,
"leading_planet": sorted_planets[0].name,
"rim_start": sorted_planets[0].name,
"rim_end": sorted_planets[-1].name,
}
# 3. Bucket: Bowl + one isolated planet as "handle"
# Check if largest gap is > 180° and there's one planet isolated
if largest_gap["degrees"] > 180:
# Check if the remaining planets form a bowl
remaining_planets = [p for p in sorted_planets if p != largest_gap["from"]]
if len(remaining_planets) >= 2:
remaining_span = _calculate_span(remaining_planets)
if remaining_span <= 180:
return "Bucket", {
"span": remaining_span,
"handle": largest_gap["from"].name,
"rim_start": remaining_planets[0].name,
"rim_end": remaining_planets[-1].name,
}
# 4. Locomotive: Planets in 240° with 120° gap
if 210 <= span <= 270 and largest_gap["degrees"] >= 100:
return "Locomotive", {
"span": span,
"gap": largest_gap["degrees"],
"leading_planet": sorted_planets[0].name,
}
# 5. Seesaw: Two opposing groups with large gap between them
# Look for two significant gaps (> 60°)
significant_gaps = [g for g in gaps if g["degrees"] > 60]
if len(significant_gaps) >= 2:
# Check if gaps are roughly opposite each other
gap1, gap2 = significant_gaps[0], significant_gaps[1]
gap_separation = abs(gap1["degrees"] - gap2["degrees"])
if gap_separation < 60: # Gaps are similar size
return "Seesaw", {
"gap1": gap1["degrees"],
"gap2": gap2["degrees"],
"group1_start": gap1["to"].name,
"group2_start": gap2["to"].name,
}
# 6. Splay: Irregular distribution with multiple gaps
# At least 3 gaps of 60°+
large_gaps = [g for g in gaps if g["degrees"] >= 60]
if len(large_gaps) >= 3:
return "Splay", {
"num_gaps": len(large_gaps),
"irregular": True,
}
# 7. Splash: Default - planets evenly distributed
# No clear pattern detected
return "Splash", {
"span": span,
"largest_gap": largest_gap["degrees"],
"distribution": "even",
}
def _calculate_span(planets: list[CelestialPosition]) -> float:
"""
Calculate the total span of planets in degrees.
Args:
planets: List of planets (should be sorted by longitude)
Returns:
Span in degrees (0-360)
"""
if len(planets) < 2:
return 0.0
# Correct approach: the span is 360° minus the largest gap between
# consecutive sorted planets. This handles the 0°/360° seam correctly.
# e.g., planets at 350°, 355°, 5°, 10° → largest gap is 340° (from 10° to 350°),
# so span = 360 - 340 = 20°. The naive (last - first) % 360 would give 340°.
longitudes = sorted(p.longitude for p in planets)
n = len(longitudes)
max_gap = 0.0
for i in range(n):
gap = (longitudes[(i + 1) % n] - longitudes[i]) % 360
if gap > max_gap:
max_gap = gap
return 360.0 - max_gap
[docs]
def get_chart_shape_description(shape: ChartShape, metadata: dict) -> str:
"""
Get a human-readable description of a chart shape.
Args:
shape: Chart shape name
metadata: Metadata dict from detect_chart_shape()
Returns:
Description string
"""
descriptions = {
"Bundle": "Focused energy, concentrated in one area",
"Bowl": "Self-contained, purposeful direction",
"Bucket": "Bowl with singular focus point",
"Locomotive": "Dynamic, driven, constant motion",
"Seesaw": "Balanced opposites, see-saw energy",
"Splay": "Individualistic, strong-willed",
"Splash": "Well-rounded, versatile",
}
base_desc = descriptions.get(shape, "")
# Add metadata details
if shape == "Bundle" and "span" in metadata:
return f"{base_desc} ({metadata['span']:.0f}° span)"
elif shape == "Bucket" and "handle" in metadata:
return f"{base_desc} (handle: {metadata['handle']})"
elif shape == "Locomotive" and "gap" in metadata:
return f"{base_desc} ({metadata['gap']:.0f}° gap)"
return base_desc