1use serde::Serialize;
4
5use crate::aspects::Aspect;
6use crate::chart::Placement;
7use crate::pyfloat;
8
9type 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#[derive(Debug, Clone, PartialEq, Serialize)]
57pub struct Qualities {
58 pub hot: f64,
60 pub cold: f64,
62 pub wet: f64,
64 pub dry: f64,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct Scores {
71 pub choleric: f64,
73 pub sanguine: f64,
75 pub phlegmatic: f64,
77 pub melancholic: f64,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize)]
83pub struct Factor {
84 pub factor: &'static str,
86 pub details: String,
88 pub hot: f64,
90 pub cold: f64,
92 pub wet: f64,
94 pub dry: f64,
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize)]
100pub struct Temperament {
101 pub primary_temperament: &'static str,
103 pub secondary_temperament: &'static str,
105 pub scores: Scores,
107 pub percentages: Scores,
109 pub qualities: Qualities,
111 pub quality_percentages: Qualities,
113 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 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 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}