feat: stellium detection (3+ planets clustered in one sign or house)

A fourth classic natal-chart pattern with no prior support — "you have
a stellium in Capricorn" is a well-known astrological observation this
app had no way to surface despite already deriving each planet's sign
and house. Adds zodia_core::stelliums_by_sign/stelliums_by_house
(conventional 3-body minimum), test-driven with synthetic
PlanetPositions so the grouping logic is verified independent of real
ephemeris output. House-based grouping correctly returns empty for
stub charts (all-zero cusps) rather than producing meaningless groups
from house 0 for every planet.

Surfaced as extra rows in the Chart tab's Balance section, one per
detected stellium — silent (no rows) for charts with no stellium,
which is the common case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-03 17:52:00 +02:00
co-authored by Claude Sonnet 5
parent 2924a799ea
commit cbbc6532b3
3 changed files with 137 additions and 0 deletions
+21
View File
@@ -258,6 +258,27 @@ impl SimpleAsyncComponent for AspectView {
modalities_row.set_activatable(false);
balance_group.add(&modalities_row);
// Stelliums — 3+ planets clustered in one sign or house, a
// well-known "concentration of emphasis" pattern with no prior
// home in the UI at all.
for (sign, planets) in zodia_core::stelliums_by_sign(&chart.positions) {
let name = zodia_core::interp::sign_name_lower(sign);
let capitalized = name.chars().next().map(|c| c.to_ascii_uppercase()).into_iter()
.chain(name.chars().skip(1)).collect::<String>();
let row = adw::ActionRow::new();
row.set_title(&format!("Stellium in {capitalized}"));
row.set_subtitle(&planets.iter().map(|p| p.symbol()).collect::<Vec<_>>().join(" "));
row.set_activatable(false);
balance_group.add(&row);
}
for (house, planets) in zodia_core::stelliums_by_house(chart) {
let row = adw::ActionRow::new();
row.set_title(&format!("Stellium in House {house}"));
row.set_subtitle(&planets.iter().map(|p| p.symbol()).collect::<Vec<_>>().join(" "));
row.set_activatable(false);
balance_group.add(&row);
}
widgets.content_box.prepend(&balance_group);
}
+2
View File
@@ -9,6 +9,7 @@ pub mod houses;
pub mod interp;
pub mod moon_phase;
pub mod planet;
pub mod stellium;
pub mod topic;
pub mod transit;
@@ -25,6 +26,7 @@ pub use houses::{HouseError, HouseKind, HouseSystem};
pub use interp::{Angle, InterpKey, InterpKind, humanize_key, parse_interp_sig};
pub use moon_phase::{MoonPhase, phase_at as moon_phase_at, phase_from_longitudes as moon_phase_from_longitudes};
pub use planet::{Planet, PlanetPositions};
pub use stellium::{stelliums_by_house, stelliums_by_sign};
pub use topic::{TopicKey, solar_longitude, solar_month, topic_key_for_interp,
topic_key_global, topic_keys_for_chart};
pub use transit::{HouseTransit, TransitAspect, TransitSet, build_transit_set,
+114
View File
@@ -0,0 +1,114 @@
//! Stellium detection — 3+ planets clustered in the same sign or house, a
//! well-known astrological pattern (conventionally read as a concentration
//! of energy/emphasis) that Zodia never surfaced despite already deriving
//! each planet's sign and house for placements.
use std::collections::HashMap;
use crate::chart::Chart;
use crate::planet::{Planet, PlanetPositions};
/// Conventional minimum: fewer than 3 bodies sharing a sign/house isn't
/// called a stellium by any astrological reference this app follows.
const MIN_STELLIUM_SIZE: usize = 3;
/// Groups of 3+ planets sharing the same zodiac sign (0 = Aries … 11 =
/// Pisces), sorted by sign index. Angles (ASC/MC) are excluded — same
/// convention as `natal_balance`, which counts planets, not chart points.
pub fn stelliums_by_sign(positions: &PlanetPositions) -> Vec<(u8, Vec<Planet>)> {
let mut by_sign: HashMap<u8, Vec<Planet>> = HashMap::new();
for &planet in Planet::all() {
let Some(lon) = positions.get(planet) else { continue };
let sign = (lon.rem_euclid(360.0) / 30.0).floor() as u8 % 12;
by_sign.entry(sign).or_default().push(planet);
}
let mut groups: Vec<(u8, Vec<Planet>)> = by_sign.into_iter()
.filter(|(_, planets)| planets.len() >= MIN_STELLIUM_SIZE)
.collect();
groups.sort_by_key(|(sign, _)| *sign);
groups
}
/// Groups of 3+ planets sharing the same house (1-12), sorted by house
/// number. Empty when the chart's houses are a stub (geohash too coarse for
/// a real house computation) — a house-based stellium is meaningless
/// without real house cusps.
pub fn stelliums_by_house(chart: &Chart) -> Vec<(u8, Vec<Planet>)> {
let is_stub = chart.houses.cusps.iter().all(|&c| c == 0.0);
if is_stub {
return Vec::new();
}
let mut by_house: HashMap<u8, Vec<Planet>> = HashMap::new();
for &planet in Planet::all() {
let Some(lon) = chart.positions.get(planet) else { continue };
let house = chart.houses.house_of(lon);
by_house.entry(house).or_default().push(planet);
}
let mut groups: Vec<(u8, Vec<Planet>)> = by_house.into_iter()
.filter(|(_, planets)| planets.len() >= MIN_STELLIUM_SIZE)
.collect();
groups.sort_by_key(|(house, _)| *house);
groups
}
#[cfg(test)]
mod tests {
use super::*;
fn positions_from(pairs: &[(Planet, f64)]) -> PlanetPositions {
PlanetPositions(pairs.iter().copied().collect())
}
#[test]
fn three_planets_in_one_sign_is_a_stellium() {
// All three in Taurus (30-60°), spread across the sign so this
// isn't just "three planets at the same degree" but a real
// same-sign cluster.
let positions = positions_from(&[
(Planet::Sun, 35.0), (Planet::Mercury, 42.0), (Planet::Venus, 58.0),
(Planet::Mars, 200.0), // elsewhere, shouldn't join the group
]);
let groups = stelliums_by_sign(&positions);
assert_eq!(groups.len(), 1);
let (sign, mut planets) = groups[0].clone();
assert_eq!(sign, 1); // Taurus
planets.sort_by_key(|p| format!("{p:?}"));
assert_eq!(planets.len(), 3);
assert!(planets.contains(&Planet::Sun));
assert!(planets.contains(&Planet::Mercury));
assert!(planets.contains(&Planet::Venus));
}
#[test]
fn two_planets_in_one_sign_is_not_a_stellium() {
let positions = positions_from(&[
(Planet::Sun, 35.0), (Planet::Mercury, 42.0),
(Planet::Mars, 200.0), (Planet::Venus, 210.0),
]);
assert_eq!(stelliums_by_sign(&positions).len(), 0, "no group has 3+");
}
#[test]
fn planets_spread_across_all_signs_yield_no_stellium() {
// One planet per sign, 10 signs used out of 12 — every group has
// exactly 1 member, nowhere near the 3-body threshold.
let positions = positions_from(&[
(Planet::Sun, 5.0), (Planet::Moon, 35.0), (Planet::Mercury, 65.0),
(Planet::Venus, 95.0), (Planet::Mars, 125.0), (Planet::Jupiter, 155.0),
(Planet::Saturn, 185.0), (Planet::Uranus, 215.0), (Planet::Neptune, 245.0),
(Planet::Pluto, 275.0),
]);
assert_eq!(stelliums_by_sign(&positions).len(), 0);
}
#[test]
fn house_stellium_is_empty_for_a_stub_chart() {
// A stub chart (geohash too coarse for real houses) has all-zero
// cusps — house-based grouping would be meaningless noise there,
// not a real astrological signal.
let birth = crate::birth::birth_from_coords(2_451_545.0, 59.9, 10.7, 9);
let mut chart = Chart::compute(birth).expect("chart computes");
chart.houses = crate::houses::HouseSystem::stub();
assert_eq!(stelliums_by_house(&chart).len(), 0);
}
}