Skip to main content

astroceleste_engine/
temperament.rs

1//! Traditional temperament from weighted primary qualities (`charts/calc/temperament.py`).
2
3use serde::Serialize;
4
5use crate::aspects::Aspect;
6use crate::chart::Placement;
7use crate::dignities::{element as element_of, traditional_ruler};
8use crate::minor_bodies::is_minor_body;
9use crate::pyfloat;
10
11/// Hot, cold, wet, dry.
12type Q = [f64; 4];
13
14fn element_qualities(element: &str) -> Q {
15    match element {
16        "Fire" => [1.0, 0.0, 0.0, 1.0],
17        "Air" => [1.0, 0.0, 1.0, 0.0],
18        "Water" => [0.0, 1.0, 1.0, 0.0],
19        "Earth" => [0.0, 1.0, 0.0, 1.0],
20        _ => [0.0; 4],
21    }
22}
23
24fn planet_qualities(planet: &str) -> Option<Q> {
25    Some(match planet {
26        "Sun" | "Mars" | "Uranus" | "Pluto" => [1.0, 0.0, 0.0, 1.0],
27        "Moon" | "Venus" | "Neptune" => [0.0, 1.0, 1.0, 0.0],
28        "Mercury" | "Saturn" => [0.0, 1.0, 0.0, 1.0],
29        "Jupiter" => [1.0, 0.0, 1.0, 0.0],
30        _ => return None,
31    })
32}
33
34/// The four primary qualities.
35#[derive(Debug, Clone, PartialEq, Serialize)]
36pub struct Qualities {
37    /// Hot.
38    pub hot: f64,
39    /// Cold.
40    pub cold: f64,
41    /// Wet.
42    pub wet: f64,
43    /// Dry.
44    pub dry: f64,
45}
46
47/// The four temperaments.
48#[derive(Debug, Clone, PartialEq, Serialize)]
49pub struct Scores {
50    /// Choleric (hot and dry).
51    pub choleric: f64,
52    /// Sanguine (hot and wet).
53    pub sanguine: f64,
54    /// Phlegmatic (cold and wet).
55    pub phlegmatic: f64,
56    /// Melancholic (cold and dry).
57    pub melancholic: f64,
58}
59
60/// One contribution to the temperament, with its weighted qualities.
61#[derive(Debug, Clone, PartialEq, Serialize)]
62pub struct Factor {
63    /// What contributes, e.g. "Ascendant Sign".
64    pub factor: &'static str,
65    /// The placement behind it, e.g. "Leo (Fire)".
66    pub details: String,
67    /// Weighted hot.
68    pub hot: f64,
69    /// Weighted cold.
70    pub cold: f64,
71    /// Weighted wet.
72    pub wet: f64,
73    /// Weighted dry.
74    pub dry: f64,
75}
76
77/// Temperament assessment from the chart's qualities.
78#[derive(Debug, Clone, PartialEq, Serialize)]
79pub struct Temperament {
80    /// Highest-scoring temperament.
81    pub primary_temperament: &'static str,
82    /// Second-highest temperament, or "" when it scores zero.
83    pub secondary_temperament: &'static str,
84    /// Temperament scores.
85    pub scores: Scores,
86    /// Temperament scores as percentages of their total.
87    pub percentages: Scores,
88    /// Totals of the four qualities.
89    pub qualities: Qualities,
90    /// Quality totals as percentages.
91    pub quality_percentages: Qualities,
92    /// Every contributing factor.
93    pub breakdown: Vec<Factor>,
94}
95
96pub fn temperament(planets: &[Placement], aspects: &[Aspect]) -> Temperament {
97    let mut totals: Q = [0.0; 4];
98    let mut breakdown = Vec::new();
99    let mut add = |factor: &'static str, details: String, q: Q, weight: f64| {
100        let w = q.map(|v| v * weight);
101        for i in 0..4 {
102            totals[i] += w[i];
103        }
104        breakdown.push(Factor {
105            factor,
106            details,
107            hot: pyfloat::round(w[0], 1),
108            cold: pyfloat::round(w[1], 1),
109            wet: pyfloat::round(w[2], 1),
110            dry: pyfloat::round(w[3], 1),
111        });
112    };
113    // Python builds a name → planet dict: the last entry of a name wins.
114    let by_name = |name: &str| planets.iter().rev().find(|p| p.name == name);
115    let sun = by_name("Sun");
116    let moon = by_name("Moon");
117
118    if let Some(asc) = by_name("Ascendant") {
119        let element = element_of(asc.sign).unwrap_or("Fire");
120        add(
121            "Ascendant Sign",
122            format!("{} ({element})", asc.sign),
123            element_qualities(element),
124            4.0,
125        );
126        let lord = traditional_ruler(asc.sign).unwrap_or("Mars");
127        if let Some(lord_planet) = by_name(lord) {
128            add(
129                "Lord of Ascendant Nature",
130                lord.to_string(),
131                planet_qualities(lord).unwrap_or_default(),
132                3.0,
133            );
134            let lord_element = element_of(lord_planet.sign).unwrap_or("Fire");
135            add(
136                "Lord of Ascendant Sign",
137                format!("{lord} in {} ({lord_element})", lord_planet.sign),
138                element_qualities(lord_element),
139                3.0,
140            );
141        }
142    }
143
144    if let Some(sun) = sun {
145        let element = element_of(sun.sign).unwrap_or("Fire");
146        add(
147            "Sun Sign",
148            format!("{} ({element})", sun.sign),
149            element_qualities(element),
150            4.0,
151        );
152        let (season, q) = match sun.sign {
153            "Aries" | "Taurus" | "Gemini" => ("Spring (Hot & Wet)", [1.0, 0.0, 1.0, 0.0]),
154            "Cancer" | "Leo" | "Virgo" => ("Summer (Hot & Dry)", [1.0, 0.0, 0.0, 1.0]),
155            "Libra" | "Scorpio" | "Sagittarius" => ("Autumn (Cold & Dry)", [0.0, 1.0, 0.0, 1.0]),
156            _ => ("Winter (Cold & Wet)", [0.0, 1.0, 1.0, 0.0]),
157        };
158        add("Solar Season", season.to_string(), q, 2.0);
159    }
160
161    if let Some(moon) = moon {
162        let element = element_of(moon.sign).unwrap_or("Water");
163        add(
164            "Moon Sign",
165            format!("{} ({element})", moon.sign),
166            element_qualities(element),
167            4.0,
168        );
169        if let Some(sun) = sun {
170            let diff = pyfloat::rem(moon.ecliptic_longitude - sun.ecliptic_longitude, 360.0);
171            let (phase, q) = if diff < 90.0 {
172                ("1st Quarter Waxing (Hot & Wet)", [1.0, 0.0, 1.0, 0.0])
173            } else if diff < 180.0 {
174                ("2nd Quarter Gibbous (Hot & Dry)", [1.0, 0.0, 0.0, 1.0])
175            } else if diff < 270.0 {
176                ("3rd Quarter Full/Waning (Cold & Dry)", [0.0, 1.0, 0.0, 1.0])
177            } else {
178                ("4th Quarter Crescent (Cold & Wet)", [0.0, 1.0, 1.0, 0.0])
179            };
180            add("Lunar Phase", phase.to_string(), q, 2.0);
181        }
182    }
183
184    for p in planets {
185        // The minor bodies came after the traditional assessment and take no part in it.
186        if matches!(
187            p.name,
188            "Ascendant" | "Midheaven" | "North Node" | "South Node" | "Part of Fortune"
189        ) || is_minor_body(p.name)
190        {
191            continue;
192        }
193        if p.house == 1 {
194            let pq = planet_qualities(p.name).unwrap_or_default();
195            let sq = element_qualities(element_of(p.sign).unwrap_or("Fire"));
196            let combined = [0, 1, 2, 3].map(|i| pq[i] + sq[i]);
197            add(
198                "Planet in House 1",
199                format!("{} in {}", p.name, p.sign),
200                combined,
201                1.0,
202            );
203        }
204    }
205
206    for a in aspects {
207        if a.body1 == "Ascendant" || a.body2 == "Ascendant" {
208            let other = if a.body1 == "Ascendant" {
209                &a.body2
210            } else {
211                &a.body1
212            };
213            if let Some(q) = planet_qualities(other) {
214                add(
215                    "Aspect to Ascendant",
216                    format!("{other} {} ASC", a.aspect_type),
217                    q,
218                    1.0,
219                );
220            }
221        }
222    }
223
224    let [hot, cold, wet, dry] = totals;
225    let (choleric, sanguine, phlegmatic, melancholic) =
226        (hot + dry, hot + wet, cold + wet, cold + dry);
227    let total_q = (hot + cold + wet + dry).max(1.0);
228    let total_t = (choleric + sanguine + phlegmatic + melancholic).max(1.0);
229    let pct = |v: f64, total: f64| pyfloat::round((v / total) * 100.0, 1);
230
231    // Stable sort, highest first: ties keep this order, as Python's sorted(reverse=True).
232    let mut ranked = [
233        ("Choleric", choleric),
234        ("Sanguine", sanguine),
235        ("Phlegmatic", phlegmatic),
236        ("Melancholic", melancholic),
237    ];
238    ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
239
240    Temperament {
241        primary_temperament: ranked[0].0,
242        secondary_temperament: if ranked[1].1 > 0.0 { ranked[1].0 } else { "" },
243        scores: Scores {
244            choleric: pyfloat::round(choleric, 1),
245            sanguine: pyfloat::round(sanguine, 1),
246            phlegmatic: pyfloat::round(phlegmatic, 1),
247            melancholic: pyfloat::round(melancholic, 1),
248        },
249        percentages: Scores {
250            choleric: pct(choleric, total_t),
251            sanguine: pct(sanguine, total_t),
252            phlegmatic: pct(phlegmatic, total_t),
253            melancholic: pct(melancholic, total_t),
254        },
255        qualities: Qualities {
256            hot: pyfloat::round(hot, 1),
257            cold: pyfloat::round(cold, 1),
258            wet: pyfloat::round(wet, 1),
259            dry: pyfloat::round(dry, 1),
260        },
261        quality_percentages: Qualities {
262            hot: pct(hot, total_q),
263            cold: pct(cold, total_q),
264            wet: pct(wet, total_q),
265            dry: pct(dry, total_q),
266        },
267        breakdown,
268    }
269}