"""
Statistical aggregation for chart collections.
ChartStats provides methods for computing aggregate statistics across
multiple charts, including element distributions, sign frequencies,
aspect counts, and cross-tabulations.
"""
from collections import Counter
from collections.abc import Sequence
from typing import Any
from stellium.analysis.frames import (
_count_elements,
_count_modalities,
_has_pattern,
_require_pandas,
)
from stellium.core.models import CalculatedChart
# All zodiac signs in order
ZODIAC_SIGNS = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
]
[docs]
class ChartStats:
"""
Statistical aggregation across chart collections.
Computes distributions, frequencies, and cross-tabulations for
research and analysis purposes.
Example::
from stellium.analysis import BatchCalculator, ChartStats
charts = BatchCalculator.from_registry(category="scientist").calculate_all()
stats = ChartStats(charts)
# Element distribution
print(stats.element_distribution())
# Sun sign frequency
print(stats.sign_distribution("Sun"))
# Cross-tabulation
print(stats.cross_tab("sun_sign", "moon_sign"))
"""
def __init__(self, charts: Sequence[CalculatedChart]) -> None:
"""
Initialize with a collection of charts.
Args:
charts: Sequence of CalculatedChart objects to analyze
"""
self._charts = list(charts)
@property
def chart_count(self) -> int:
"""Number of charts in the collection."""
return len(self._charts)
# ---- Element & Modality ----
[docs]
def element_distribution(self, normalize: bool = True) -> dict[str, float]:
"""
Calculate element distribution across all charts.
Counts planets in each element across all charts and returns
the distribution as proportions (default) or raw counts.
Args:
normalize: Return proportions (0-1) instead of counts
Returns:
Dictionary with element names as keys and counts/proportions as values
Example::
stats.element_distribution()
# {'fire': 0.28, 'earth': 0.31, 'air': 0.22, 'water': 0.19}
stats.element_distribution(normalize=False)
# {'fire': 280, 'earth': 310, 'air': 220, 'water': 190}
"""
totals: dict[str, int] = {"fire": 0, "earth": 0, "air": 0, "water": 0}
for chart in self._charts:
counts = _count_elements(chart)
for element, count in counts.items():
totals[element] += count
if normalize:
total = sum(totals.values())
if total > 0:
return {k: v / total for k, v in totals.items()}
return dict.fromkeys(totals, 0.0)
return {k: float(v) for k, v in totals.items()}
[docs]
def modality_distribution(self, normalize: bool = True) -> dict[str, float]:
"""
Calculate modality distribution across all charts.
Args:
normalize: Return proportions (0-1) instead of counts
Returns:
Dictionary with modality names as keys and counts/proportions as values
Example::
stats.modality_distribution()
# {'cardinal': 0.35, 'fixed': 0.33, 'mutable': 0.32}
"""
totals: dict[str, int] = {"cardinal": 0, "fixed": 0, "mutable": 0}
for chart in self._charts:
counts = _count_modalities(chart)
for modality, count in counts.items():
totals[modality] += count
if normalize:
total = sum(totals.values())
if total > 0:
return {k: v / total for k, v in totals.items()}
return dict.fromkeys(totals, 0.0)
return {k: float(v) for k, v in totals.items()}
# ---- Sign Distribution ----
[docs]
def sign_distribution(
self,
object_name: str,
normalize: bool = False,
) -> dict[str, int | float]:
"""
Count sign placements for a specific object across all charts.
Args:
object_name: Name of the object (e.g., "Sun", "Moon", "ASC")
normalize: Return proportions instead of counts
Returns:
Dictionary with sign names as keys and counts as values
Example::
stats.sign_distribution("Sun")
# {'Aries': 45, 'Taurus': 38, 'Gemini': 42, ...}
stats.sign_distribution("Moon", normalize=True)
# {'Aries': 0.08, 'Taurus': 0.09, ...}
"""
counts: Counter[str] = Counter()
for chart in self._charts:
obj = chart.get_object(object_name)
if obj is not None:
counts[obj.sign] += 1
# Ensure all signs are represented
result: dict[str, int] = {sign: counts.get(sign, 0) for sign in ZODIAC_SIGNS}
if normalize:
total = sum(result.values())
if total > 0:
return {k: v / total for k, v in result.items()}
return dict.fromkeys(result, 0.0)
return result
[docs]
def house_distribution(
self,
object_name: str,
house_system: str | None = None,
normalize: bool = False,
) -> dict[int, int | float]:
"""
Count house placements for a specific object across all charts.
Args:
object_name: Name of the object (e.g., "Sun", "Moon")
house_system: House system to use (default: chart's default)
normalize: Return proportions instead of counts
Returns:
Dictionary with house numbers (1-12) as keys and counts as values
Example::
stats.house_distribution("Sun")
# {1: 35, 2: 42, 3: 38, ..., 12: 41}
"""
counts: Counter[int] = Counter()
for chart in self._charts:
try:
system = house_system or chart.default_house_system
placements = chart.house_placements.get(system, {})
house = placements.get(object_name)
if house is not None:
counts[house] += 1
except ValueError:
# No house system available
continue
# Ensure all houses are represented
result: dict[int, int] = {h: counts.get(h, 0) for h in range(1, 13)}
if normalize:
total = sum(result.values())
if total > 0:
return {k: v / total for k, v in result.items()}
return dict.fromkeys(result, 0.0)
return result
# ---- Aspect Statistics ----
[docs]
def aspect_frequency(self, normalize: bool = False) -> dict[str, int | float]:
"""
Count aspect types across all charts.
Args:
normalize: Return proportions instead of counts
Returns:
Dictionary with aspect names as keys and counts as values
Example::
stats.aspect_frequency()
# {'Conjunction': 1234, 'Square': 987, 'Trine': 876, ...}
"""
counts: Counter[str] = Counter()
for chart in self._charts:
for aspect in chart.aspects:
counts[aspect.aspect_name] += 1
result = dict(counts.most_common())
if normalize:
total = sum(result.values())
if total > 0:
return {k: v / total for k, v in result.items()}
return dict.fromkeys(result, 0.0)
return result
[docs]
def aspect_pair_frequency(
self,
object1: str,
object2: str,
) -> dict[str, int]:
"""
Count aspect types between two specific objects.
Args:
object1: First object name
object2: Second object name
Returns:
Dictionary with aspect names as keys and counts as values
Example::
stats.aspect_pair_frequency("Sun", "Moon")
# {'Conjunction': 45, 'Sextile': 38, 'Square': 42, ...}
"""
counts: Counter[str] = Counter()
for chart in self._charts:
for aspect in chart.aspects:
names = {aspect.object1.name, aspect.object2.name}
if object1 in names and object2 in names:
counts[aspect.aspect_name] += 1
return dict(counts)
# ---- Pattern Statistics ----
[docs]
def pattern_frequency(self) -> dict[str, int]:
"""
Count aspect patterns across all charts.
Returns:
Dictionary with pattern names as keys and counts as values
Example::
stats.pattern_frequency()
# {'Grand Trine': 23, 'T-Square': 45, 'Yod': 12, ...}
"""
counts: Counter[str] = Counter()
pattern_names = [
"Grand Trine",
"T-Square",
"Grand Cross",
"Yod",
"Kite",
"Mystic Rectangle",
"Stellium",
]
for chart in self._charts:
for pattern_name in pattern_names:
if _has_pattern(chart, pattern_name):
counts[pattern_name] += 1
return dict(counts)
# ---- Retrograde Statistics ----
[docs]
def retrograde_frequency(self, normalize: bool = False) -> dict[str, int | float]:
"""
Count retrograde occurrences by planet.
Args:
normalize: Return proportions instead of counts
Returns:
Dictionary with planet names as keys and retrograde counts as values
Example::
stats.retrograde_frequency()
# {'Mercury': 89, 'Venus': 23, 'Mars': 45, ...}
"""
counts: Counter[str] = Counter()
totals: Counter[str] = Counter()
for chart in self._charts:
for planet in chart.get_planets():
totals[planet.name] += 1
if planet.is_retrograde:
counts[planet.name] += 1
if normalize:
result: dict[str, float] = {}
for name, total in totals.items():
if total > 0:
result[name] = counts.get(name, 0) / total
else:
result[name] = 0.0
return result
return dict(counts)
# ---- Sect Statistics ----
[docs]
def sect_distribution(self) -> dict[str, int]:
"""
Count day vs night charts.
Returns:
Dictionary with "day" and "night" keys and counts as values
Example::
stats.sect_distribution()
# {'day': 523, 'night': 477}
"""
counts: Counter[str] = Counter()
for chart in self._charts:
sect = chart.sect()
if sect:
counts[sect.lower()] += 1
return dict(counts)
# ---- Cross-Tabulation ----
[docs]
def cross_tab(
self,
row_var: str,
col_var: str,
) -> Any:
"""
Create a cross-tabulation (contingency table) of two variables.
Requires pandas: pip install stellium[analysis]
Supported variables:
- "sun_sign", "moon_sign", "asc_sign", "mc_sign"
- "sun_house", "moon_house", etc. (any object + "_house")
- "sect" (day/night)
- Any object name followed by "_sign" or "_house"
Args:
row_var: Variable for rows
col_var: Variable for columns
Returns:
pandas DataFrame with cross-tabulation
Example::
# Sun sign vs Moon sign
df = stats.cross_tab("sun_sign", "moon_sign")
# Sun sign vs sect
df = stats.cross_tab("sun_sign", "sect")
"""
_require_pandas()
import pandas as pd
row_data = []
col_data = []
for chart in self._charts:
row_val = self._get_variable(chart, row_var)
col_val = self._get_variable(chart, col_var)
if row_val is not None and col_val is not None:
row_data.append(row_val)
col_data.append(col_val)
df = pd.DataFrame({row_var: row_data, col_var: col_data})
return pd.crosstab(df[row_var], df[col_var])
def _get_variable(self, chart: CalculatedChart, var_name: str) -> Any:
"""Extract a variable value from a chart."""
# Sect
if var_name == "sect":
return chart.sect()
# Sign variables (object_sign)
if var_name.endswith("_sign"):
obj_name = var_name[:-5] # Remove "_sign"
# Map common names
obj_name = {
"sun": "Sun",
"moon": "Moon",
"asc": "ASC",
"mc": "MC",
"mercury": "Mercury",
"venus": "Venus",
"mars": "Mars",
"jupiter": "Jupiter",
"saturn": "Saturn",
}.get(obj_name.lower(), obj_name)
obj = chart.get_object(obj_name)
return obj.sign if obj else None
# House variables (object_house)
if var_name.endswith("_house"):
obj_name = var_name[:-6] # Remove "_house"
obj_name = {
"sun": "Sun",
"moon": "Moon",
"mercury": "Mercury",
"venus": "Venus",
"mars": "Mars",
"jupiter": "Jupiter",
"saturn": "Saturn",
}.get(obj_name.lower(), obj_name)
return chart.get_house(obj_name)
return None
# ---- Summary ----
[docs]
def summary(self) -> dict[str, Any]:
"""
Generate a comprehensive summary of the chart collection.
Returns:
Dictionary with various statistics
Example::
summary = stats.summary()
print(summary['chart_count'])
print(summary['element_distribution'])
"""
return {
"chart_count": self.chart_count,
"element_distribution": self.element_distribution(),
"modality_distribution": self.modality_distribution(),
"sect_distribution": self.sect_distribution(),
"pattern_frequency": self.pattern_frequency(),
"retrograde_frequency": self.retrograde_frequency(),
"sun_sign_distribution": self.sign_distribution("Sun"),
"moon_sign_distribution": self.sign_distribution("Moon"),
}
def __repr__(self) -> str:
return f"<ChartStats: {self.chart_count} charts>"
def __len__(self) -> int:
return self.chart_count