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::calculate_planets;
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(
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/// Judge the question: significators, perfection and what helps or hinders it.
226#[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        // Prohibition: another planet reaches either significator first.
293        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        // Refranation: a significator stations before the aspect is exact.
309        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        // Translation: a lighter planet separates from one significator and applies to the
322        // other; collection: both significators apply to a heavier one.
323        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
358/// The signs on the twelve cusps.
359pub(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        // Moon 10° Aries (13°/day) to Jupiter 20° Leo (0.1°/day): trine in about 0.77 days.
377        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        // Moon 28° Aries must cover 12° to trine Jupiter at 10° Leo: it leaves Aries first.
387        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        // Mercury 15° Gemini (1.5°/day) past a sextile to Mars 10° Aries (0.5°/day).
393        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        // Venus 12° Taurus retrograde (−0.5°/day) to a square with Saturn 10° Leo (0.03°/day).
401        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        // The station is at 2024-04-01 ~22h UTC, 7.9 days on; samples are half a day apart.
430        assert!((days - 7.9).abs() <= 0.6, "{days}");
431        assert!(station_within(&kernels, jd, 0.0, body, 5.0).is_none());
432    }
433}