Skip to main content

astroceleste_engine/
judgment.rs

1//! Horary judgment after Lilly: the significators of the querent and of the quesited, and
2//! whether and how the matter perfects between them (by aspect, translation of light or
3//! collection), or is prevented (prohibition, refranation, a significator leaving its sign).
4//!
5//! Not part of the reference implementation. Perfection is projected from the planets'
6//! present speeds, as the Moon's applying aspects are; refranation checks the ephemeris for
7//! a station before the projected perfection.
8
9use 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
18/// The longest span a station is searched for, in days.
19const MAX_REFRANATION_DAYS: f64 = 400.0;
20
21/// An aspect that becomes exact between two planets, both still in their present signs.
22#[derive(Debug, Clone, PartialEq, Serialize)]
23pub struct Perfection {
24    /// The planet that applies (the faster one).
25    pub applying: &'static str,
26    /// The planet applied to.
27    pub to: &'static str,
28    /// Ptolemaic aspect, e.g. "Trine".
29    pub aspect: &'static str,
30    /// Degrees still to cover before the aspect is exact (rounded to 0.01).
31    pub degrees: f64,
32    /// Days until it is exact at the present speeds (rounded to 0.01).
33    pub days: f64,
34}
35
36/// A third planet that perfects an aspect with one of the significators first.
37#[derive(Debug, Clone, PartialEq, Serialize)]
38pub struct Prohibition {
39    /// The planet that comes between.
40    pub by: &'static str,
41    /// The significator it reaches first.
42    pub significator: &'static str,
43    /// Ptolemaic aspect.
44    pub aspect: &'static str,
45    /// Days until it is exact (rounded to 0.01).
46    pub days: f64,
47}
48
49/// A lighter planet carrying the light of one significator to the other.
50#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct Translation {
52    /// The planet that translates.
53    pub by: &'static str,
54    /// The significator it has separated from.
55    pub from: &'static str,
56    /// The aspect it separated from.
57    pub from_aspect: &'static str,
58    /// The significator it applies to.
59    pub to: &'static str,
60    /// The aspect it applies by.
61    pub to_aspect: &'static str,
62}
63
64/// A heavier planet that both significators apply to.
65#[derive(Debug, Clone, PartialEq, Serialize)]
66pub struct Collection {
67    /// The planet that collects the light.
68    pub by: &'static str,
69    /// The querent's significator's aspect to it.
70    pub querent_aspect: &'static str,
71    /// The quesited's significator's aspect to it.
72    pub quesited_aspect: &'static str,
73}
74
75/// A significator that turns retrograde (or direct) before the aspect perfects.
76#[derive(Debug, Clone, PartialEq, Serialize)]
77pub struct Refranation {
78    /// The planet that stations.
79    pub planet: &'static str,
80    /// The aspect that would have perfected.
81    pub aspect: &'static str,
82    /// Days until the station (rounded to 0.1).
83    pub days: f64,
84}
85
86/// The judgment of a horary question.
87#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct Judgment {
89    /// The house of the matter asked about (1-12), if given.
90    pub quesited_house: Option<u8>,
91    /// The querent's significator: the lord of the Ascendant.
92    pub querent: &'static str,
93    /// The querent's co-significator: the Moon.
94    pub co_significator: &'static str,
95    /// The quesited's significator: the lord of its house cusp.
96    pub quesited: Option<&'static str>,
97    /// Whether one planet signifies both (judged then by the Moon).
98    pub same_significator: bool,
99    /// The aspect that perfects between the two significators, if any.
100    pub perfection: Option<Perfection>,
101    /// The aspect the Moon perfects with the quesited's significator, if any.
102    pub moon_perfection: Option<Perfection>,
103    /// Planets that perfect with a significator before the significators perfect.
104    pub prohibition: Option<Prohibition>,
105    /// A station of a significator before their perfection.
106    pub refranation: Option<Refranation>,
107    /// Translations of light between the significators.
108    pub translation: Vec<Translation>,
109    /// Collections of light by a heavier planet.
110    pub collection: Vec<Collection>,
111    /// Receptions between the two significators.
112    pub receptions: Vec<Reception>,
113}
114
115/// A planet's place and speed now.
116#[derive(Debug, Clone, Copy)]
117struct Body {
118    name: &'static str,
119    lon: f64,
120    speed: f64,
121}
122
123impl Body {
124    /// Days until it leaves its sign (forward or, retrograde, backward); infinite if still.
125    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
137/// The next time (days, > 0) an aspect between `a` and `b` is exact at present speeds,
138/// with both still in their signs: (aspect, days, degrees).
139fn 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            // d0 + v t ≡ target (mod 360): the smallest t > 0
150            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
159/// The last time (days ago, > 0) an aspect between `a` and `b` was exact, with both in their
160/// present signs since: (aspect, days ago).
161fn 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
173/// The faster of two bodies applies to the slower one.
174fn 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
191/// The first station of `planet` within `days`, from the ephemeris (days from now).
192fn 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/// Judge the question: significators, perfection and what helps or hinders it.
213#[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        // Prohibition: another planet reaches either significator first.
279        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        // Refranation: a significator stations before the aspect is exact.
295        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        // Translation: a lighter planet separates from one significator and applies to the
306        // other; collection: both significators apply to a heavier one.
307        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
342/// The signs on the twelve cusps.
343pub(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        // Moon 10° Aries (13°/day) to Jupiter 20° Leo (0.1°/day): trine in about 0.77 days.
361        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        // Moon 28° Aries must cover 12° to trine Jupiter at 10° Leo: it leaves Aries first.
371        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        // Mercury 15° Gemini (1.5°/day) past a sextile to Mars 10° Aries (0.5°/day).
377        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        // Venus 12° Taurus retrograde (−0.5°/day) to a square with Saturn 10° Leo (0.03°/day).
385        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        // The station is at 2024-04-01 ~22h UTC, 7.9 days on; samples are half a day apart.
414        assert!((days - 7.9).abs() <= 0.6, "{days}");
415        assert!(station_within(&kernels, jd, body, 5.0).is_none());
416    }
417}