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::calculate_planets;
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(
193 kernels: &KernelSet,
194 jd: f64,
195 shift: f64,
196 planet: Body,
197 days: f64,
198) -> Option<f64> {
199 if matches!(planet.name, "Sun" | "Moon") || days <= 0.0 {
200 return None;
201 }
202 let span = days.min(MAX_REFRANATION_DAYS);
203 let step = (span / 60.0).clamp(0.25, 5.0);
204 let speed_at = |t: f64| -> Option<f64> {
205 calculate_planets(kernels, jd + t, shift)
206 .ok()?
207 .bodies
208 .into_iter()
209 .find(|b| b.name == planet.name)
210 .map(|b| b.speed)
211 };
212 let mut t = 0.0;
213 let mut previous = planet.speed;
214 while t < span {
215 t = (t + step).min(span);
216 let speed = speed_at(t)?;
217 if speed.signum() != previous.signum() {
218 return Some(t);
219 }
220 previous = speed;
221 }
222 None
223}
224
225#[allow(clippy::too_many_arguments)]
227pub(crate) fn judge(
228 kernels: &KernelSet,
229 jd: f64,
230 shift: f64,
231 planets: &[Placement],
232 cusp_signs: &[&'static str],
233 asc_sign: &str,
234 quesited_house: Option<u8>,
235 receptions: &[Reception],
236) -> Judgment {
237 let body = |name: &str| {
238 planets.iter().find(|p| p.name == name).map(|p| Body {
239 name: p.name,
240 lon: p.ecliptic_longitude,
241 speed: p.speed,
242 })
243 };
244 let querent = traditional_ruler(asc_sign).unwrap_or("Mars");
245 let quesited = quesited_house
246 .filter(|h| (1..=12).contains(h))
247 .and_then(|h| cusp_signs.get(h as usize - 1))
248 .and_then(|sign| traditional_ruler(sign));
249 let same_significator = quesited == Some(querent);
250
251 let mut judgment = Judgment {
252 quesited_house,
253 querent,
254 co_significator: "Moon",
255 quesited,
256 same_significator,
257 perfection: None,
258 moon_perfection: None,
259 prohibition: None,
260 refranation: None,
261 translation: Vec::new(),
262 collection: Vec::new(),
263 receptions: Vec::new(),
264 };
265 let (Some(q), Some(a)) = (quesited.and_then(body), body(querent)) else {
266 return judgment;
267 };
268 if let Some(moon) = body("Moon").filter(|_| q.name != "Moon") {
269 judgment.moon_perfection = perfection(moon, q);
270 }
271 if same_significator {
272 return judgment;
273 }
274
275 judgment.receptions = receptions
276 .iter()
277 .filter(|r| {
278 (r.planet == a.name && r.receiver == q.name)
279 || (r.planet == q.name && r.receiver == a.name)
280 })
281 .cloned()
282 .collect();
283
284 let direct = perfection(a, q);
285 let others: Vec<Body> = SEVEN
286 .iter()
287 .filter(|n| **n != a.name && **n != q.name)
288 .filter_map(|n| body(n))
289 .collect();
290
291 if let Some(p) = &direct {
292 judgment.prohibition = others
294 .iter()
295 .flat_map(|x| {
296 [a, q].into_iter().filter_map(move |sig| {
297 next_aspect(*x, sig).map(|(aspect, days, _)| (x.name, sig.name, aspect, days))
298 })
299 })
300 .filter(|(_, _, _, days)| *days < p.days)
301 .min_by(|l, r| l.3.total_cmp(&r.3))
302 .map(|(by, significator, aspect, days)| Prohibition {
303 by,
304 significator,
305 aspect,
306 days: round2(days),
307 });
308 judgment.refranation = [a, q]
310 .into_iter()
311 .filter_map(|sig| {
312 station_within(kernels, jd, shift, sig, p.days).map(|days| (sig.name, days))
313 })
314 .min_by(|l, r| l.1.total_cmp(&r.1))
315 .map(|(planet, days)| Refranation {
316 planet,
317 aspect: p.aspect,
318 days: pyfloat::round(days, 1),
319 });
320 } else {
321 for x in &others {
324 let lighter = x.speed.abs() > a.speed.abs().max(q.speed.abs());
325 let heavier = x.speed.abs() < a.speed.abs().min(q.speed.abs());
326 if lighter {
327 for (from, to) in [(a, q), (q, a)] {
328 if let (Some((from_aspect, _)), Some((to_aspect, _, _))) =
329 (last_aspect(*x, from), next_aspect(*x, to))
330 {
331 judgment.translation.push(Translation {
332 by: x.name,
333 from: from.name,
334 from_aspect,
335 to: to.name,
336 to_aspect,
337 });
338 }
339 }
340 }
341 if heavier {
342 if let (Some((qa, _, _)), Some((qq, _, _))) =
343 (next_aspect(a, *x), next_aspect(q, *x))
344 {
345 judgment.collection.push(Collection {
346 by: x.name,
347 querent_aspect: qa,
348 quesited_aspect: qq,
349 });
350 }
351 }
352 }
353 }
354 judgment.perfection = direct;
355 judgment
356}
357
358pub(crate) fn cusp_signs(cusps: &[f64]) -> Vec<&'static str> {
360 cusps
361 .iter()
362 .map(|lon| SIGNS[(pyfloat::floordiv(pyfloat::rem(*lon, 360.0), 30.0) as usize) % 12])
363 .collect()
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn b(name: &'static str, lon: f64, speed: f64) -> Body {
371 Body { name, lon, speed }
372 }
373
374 #[test]
375 fn applying_trine_within_signs() {
376 let (aspect, days, degrees) =
378 next_aspect(b("Moon", 10.0, 13.0), b("Jupiter", 140.0, 0.1)).unwrap();
379 assert_eq!(aspect, "Trine");
380 assert!((days - 10.0 / 12.9).abs() < 1e-9);
381 assert!((degrees - 10.0).abs() < 1e-9);
382 }
383
384 #[test]
385 fn no_perfection_once_a_planet_leaves_its_sign() {
386 assert!(next_aspect(b("Moon", 28.0, 13.0), b("Jupiter", 130.0, 0.0)).is_none());
388 }
389
390 #[test]
391 fn separating_aspects_are_in_the_past() {
392 let (aspect, ago) = last_aspect(b("Mercury", 75.0, 1.5), b("Mars", 10.0, 0.5)).unwrap();
394 assert_eq!(aspect, "Sextile");
395 assert!((ago - 5.0).abs() < 1e-9);
396 }
397
398 #[test]
399 fn retrograde_planets_apply_backwards() {
400 let (aspect, days, _) =
402 next_aspect(b("Venus", 42.0, -0.5), b("Saturn", 130.0, 0.03)).unwrap();
403 assert_eq!(aspect, "Square");
404 assert!((days - 2.0 / 0.53).abs() < 1e-9);
405 }
406
407 #[test]
408 fn mercury_stations_retrograde_on_1_april_2024() {
409 use crate::ephemeris::{Kernel, Spk};
410 let path =
411 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../kernels/de440s.bsp");
412 if !path.exists() {
413 eprintln!("skipping: {} not found", path.display());
414 return;
415 }
416 let mut kernels = KernelSet::new();
417 kernels.push(Kernel::new("de440s.bsp", Spk::open(path).unwrap()).unwrap());
418 let jd = crate::UtcInstant::parse("2024-03-25T00:00:00Z")
419 .unwrap()
420 .julian_day();
421 let mercury = calculate_planets(&kernels, jd, 0.0)
422 .unwrap()
423 .bodies
424 .into_iter()
425 .find(|b| b.name == "Mercury")
426 .unwrap();
427 let body = b("Mercury", mercury.longitude, mercury.speed);
428 let days = station_within(&kernels, jd, 0.0, body, 30.0).unwrap();
429 assert!((days - 7.9).abs() <= 0.6, "{days}");
431 assert!(station_within(&kernels, jd, 0.0, body, 5.0).is_none());
432 }
433}