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