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:
co-authored by
Claude Sonnet 5
parent
2924a799ea
commit
cbbc6532b3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user