1use serde::Serialize;
10
11use crate::chart::Placement;
12use crate::dignities::{traditional_ruler, Reception, SEVEN, SIGNS};
13use crate::ephemeris::KernelSet;
14use crate::horary::PTOLEMAIC;
15use crate::planets::planet_speed;
16use crate::pyfloat;
17
18const MAX_REFRANATION_DAYS: f64 = 400.0;
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
23pub struct Perfection {
24 pub applying: &'static str,
26 pub to: &'static str,
28 pub aspect: &'static str,
30 pub degrees: f64,
32 pub days: f64,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize)]
38pub struct Prohibition {
39 pub by: &'static str,
41 pub significator: &'static str,
43 pub aspect: &'static str,
45 pub days: f64,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct Translation {
52 pub by: &'static str,
54 pub from: &'static str,
56 pub from_aspect: &'static str,
58 pub to: &'static str,
60 pub to_aspect: &'static str,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize)]
66pub struct Collection {
67 pub by: &'static str,
69 pub querent_aspect: &'static str,
71 pub quesited_aspect: &'static str,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize)]
77pub struct Refranation {
78 pub planet: &'static str,
80 pub aspect: &'static str,
82 pub days: f64,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct Judgment {
89 pub quesited_house: Option<u8>,
91 pub querent: &'static str,
93 pub co_significator: &'static str,
95 pub quesited: Option<&'static str>,
97 pub same_significator: bool,
99 pub perfection: Option<Perfection>,
101 pub moon_perfection: Option<Perfection>,
103 pub prohibition: Option<Prohibition>,
105 pub refranation: Option<Refranation>,
107 pub translation: Vec<Translation>,
109 pub collection: Vec<Collection>,
111 pub receptions: Vec<Reception>,
113}
114
115#[derive(Debug, Clone, Copy)]
117struct Body {
118 name: &'static str,
119 lon: f64,
120 speed: f64,
121}
122
123impl Body {
124 fn days_in_sign(self) -> f64 {
126 let within = pyfloat::rem(self.lon, 30.0);
127 if self.speed > 0.0 {
128 (30.0 - within) / self.speed
129 } else if self.speed < 0.0 {
130 within / -self.speed
131 } else {
132 f64::INFINITY
133 }
134 }
135}
136
137fn next_aspect(a: Body, b: Body) -> Option<(&'static str, f64, f64)> {
140 let v = a.speed - b.speed;
141 if v.abs() < 1e-9 {
142 return None;
143 }
144 let d0 = a.lon - b.lon;
145 let horizon = a.days_in_sign().min(b.days_in_sign());
146 let mut best: Option<(&'static str, f64, f64)> = None;
147 for (angle, aspect) in PTOLEMAIC {
148 for target in [angle, -angle] {
149 let t = pyfloat::rem((target - d0) * v.signum(), 360.0) / v.abs();
151 if t > 1e-9 && t < horizon && best.is_none_or(|(_, bt, _)| t < bt) {
152 best = Some((aspect, t, (v * t).abs()));
153 }
154 }
155 }
156 best
157}
158
159fn last_aspect(a: Body, b: Body) -> Option<(&'static str, f64)> {
162 let back = |x: Body| Body {
163 speed: -x.speed,
164 ..x
165 };
166 next_aspect(back(a), back(b)).map(|(aspect, t, _)| (aspect, t))
167}
168
169fn round2(x: f64) -> f64 {
170 pyfloat::round(x, 2)
171}
172
173fn perfection(a: Body, b: Body) -> Option<Perfection> {
175 next_aspect(a, b).map(|(aspect, days, degrees)| {
176 let (applying, to) = if a.speed.abs() >= b.speed.abs() {
177 (a.name, b.name)
178 } else {
179 (b.name, a.name)
180 };
181 Perfection {
182 applying,
183 to,
184 aspect,
185 degrees: round2(degrees),
186 days: round2(days),
187 }
188 })
189}
190
191fn station_within(kernels: &KernelSet, jd: f64, planet: Body, days: f64) -> Option<f64> {
193 if matches!(planet.name, "Sun" | "Moon") || days <= 0.0 {
194 return None;
195 }
196 let span = days.min(MAX_REFRANATION_DAYS);
197 let step = (span / 60.0).clamp(0.25, 5.0);
198 let speed_at = |t: f64| planet_speed(kernels, jd + t, planet.name).ok();
199 let mut t = 0.0;
200 let mut previous = planet.speed;
201 while t < span {
202 t = (t + step).min(span);
203 let speed = speed_at(t)?;
204 if speed.signum() != previous.signum() {
205 return Some(t);
206 }
207 previous = speed;
208 }
209 None
210}
211
212#[allow(clippy::too_many_arguments)]
214pub(crate) fn judge(
215 kernels: &KernelSet,
216 jd: f64,
217 planets: &[Placement],
218 cusp_signs: &[&'static str],
219 asc_sign: &str,
220 quesited_house: Option<u8>,
221 receptions: &[Reception],
222) -> Judgment {
223 let body = |name: &str| {
224 planets.iter().find(|p| p.name == name).map(|p| Body {
225 name: p.name,
226 lon: p.ecliptic_longitude,
227 speed: p.speed,
228 })
229 };
230 let querent = traditional_ruler(asc_sign).unwrap_or("Mars");
231 let quesited = quesited_house
232 .filter(|h| (1..=12).contains(h))
233 .and_then(|h| cusp_signs.get(h as usize - 1))
234 .and_then(|sign| traditional_ruler(sign));
235 let same_significator = quesited == Some(querent);
236
237 let mut judgment = Judgment {
238 quesited_house,
239 querent,
240 co_significator: "Moon",
241 quesited,
242 same_significator,
243 perfection: None,
244 moon_perfection: None,
245 prohibition: None,
246 refranation: None,
247 translation: Vec::new(),
248 collection: Vec::new(),
249 receptions: Vec::new(),
250 };
251 let (Some(q), Some(a)) = (quesited.and_then(body), body(querent)) else {
252 return judgment;
253 };
254 if let Some(moon) = body("Moon").filter(|_| q.name != "Moon") {
255 judgment.moon_perfection = perfection(moon, q);
256 }
257 if same_significator {
258 return judgment;
259 }
260
261 judgment.receptions = receptions
262 .iter()
263 .filter(|r| {
264 (r.planet == a.name && r.receiver == q.name)
265 || (r.planet == q.name && r.receiver == a.name)
266 })
267 .cloned()
268 .collect();
269
270 let direct = perfection(a, q);
271 let others: Vec<Body> = SEVEN
272 .iter()
273 .filter(|n| **n != a.name && **n != q.name)
274 .filter_map(|n| body(n))
275 .collect();
276
277 if let Some(p) = &direct {
278 judgment.prohibition = others
280 .iter()
281 .flat_map(|x| {
282 [a, q].into_iter().filter_map(move |sig| {
283 next_aspect(*x, sig).map(|(aspect, days, _)| (x.name, sig.name, aspect, days))
284 })
285 })
286 .filter(|(_, _, _, days)| *days < p.days)
287 .min_by(|l, r| l.3.total_cmp(&r.3))
288 .map(|(by, significator, aspect, days)| Prohibition {
289 by,
290 significator,
291 aspect,
292 days: round2(days),
293 });
294 judgment.refranation = [a, q]
296 .into_iter()
297 .filter_map(|sig| station_within(kernels, jd, sig, p.days).map(|days| (sig.name, days)))
298 .min_by(|l, r| l.1.total_cmp(&r.1))
299 .map(|(planet, days)| Refranation {
300 planet,
301 aspect: p.aspect,
302 days: pyfloat::round(days, 1),
303 });
304 } else {
305 for x in &others {
308 let lighter = x.speed.abs() > a.speed.abs().max(q.speed.abs());
309 let heavier = x.speed.abs() < a.speed.abs().min(q.speed.abs());
310 if lighter {
311 for (from, to) in [(a, q), (q, a)] {
312 if let (Some((from_aspect, _)), Some((to_aspect, _, _))) =
313 (last_aspect(*x, from), next_aspect(*x, to))
314 {
315 judgment.translation.push(Translation {
316 by: x.name,
317 from: from.name,
318 from_aspect,
319 to: to.name,
320 to_aspect,
321 });
322 }
323 }
324 }
325 if heavier {
326 if let (Some((qa, _, _)), Some((qq, _, _))) =
327 (next_aspect(a, *x), next_aspect(q, *x))
328 {
329 judgment.collection.push(Collection {
330 by: x.name,
331 querent_aspect: qa,
332 quesited_aspect: qq,
333 });
334 }
335 }
336 }
337 }
338 judgment.perfection = direct;
339 judgment
340}
341
342pub(crate) fn cusp_signs(cusps: &[f64]) -> Vec<&'static str> {
344 cusps
345 .iter()
346 .map(|lon| SIGNS[(pyfloat::floordiv(pyfloat::rem(*lon, 360.0), 30.0) as usize) % 12])
347 .collect()
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn b(name: &'static str, lon: f64, speed: f64) -> Body {
355 Body { name, lon, speed }
356 }
357
358 #[test]
359 fn applying_trine_within_signs() {
360 let (aspect, days, degrees) =
362 next_aspect(b("Moon", 10.0, 13.0), b("Jupiter", 140.0, 0.1)).unwrap();
363 assert_eq!(aspect, "Trine");
364 assert!((days - 10.0 / 12.9).abs() < 1e-9);
365 assert!((degrees - 10.0).abs() < 1e-9);
366 }
367
368 #[test]
369 fn no_perfection_once_a_planet_leaves_its_sign() {
370 assert!(next_aspect(b("Moon", 28.0, 13.0), b("Jupiter", 130.0, 0.0)).is_none());
372 }
373
374 #[test]
375 fn separating_aspects_are_in_the_past() {
376 let (aspect, ago) = last_aspect(b("Mercury", 75.0, 1.5), b("Mars", 10.0, 0.5)).unwrap();
378 assert_eq!(aspect, "Sextile");
379 assert!((ago - 5.0).abs() < 1e-9);
380 }
381
382 #[test]
383 fn retrograde_planets_apply_backwards() {
384 let (aspect, days, _) =
386 next_aspect(b("Venus", 42.0, -0.5), b("Saturn", 130.0, 0.03)).unwrap();
387 assert_eq!(aspect, "Square");
388 assert!((days - 2.0 / 0.53).abs() < 1e-9);
389 }
390
391 #[test]
392 fn mercury_stations_retrograde_on_1_april_2024() {
393 let Some(kernels) = crate::test_kernel::full_kernel() else {
394 return;
395 };
396 let jd = crate::UtcInstant::parse("2024-03-25T00:00:00Z")
397 .unwrap()
398 .julian_day();
399 let mercury = crate::planets::planets_and_sidereal_time(
400 &kernels,
401 jd,
402 0.0,
403 crate::planets::NodeType::Mean,
404 )
405 .unwrap()
406 .0
407 .bodies
408 .into_iter()
409 .find(|b| b.name == "Mercury")
410 .unwrap();
411 let body = b("Mercury", mercury.longitude, mercury.speed);
412 let days = station_within(&kernels, jd, body, 30.0).unwrap();
413 assert!((days - 7.9).abs() <= 0.6, "{days}");
415 assert!(station_within(&kernels, jd, body, 5.0).is_none());
416 }
417}