Skip to main content

astroceleste_engine/
dignities.rs

1//! Essential dignities and the condition of the seven planets: domicile, exaltation,
2//! triplicity, bounds and faces (with Lilly's points), sect, the phase to the Sun,
3//! orientality and speed, plus the receptions and antiscia between planets.
4//!
5//! Not part of the reference implementation: these keys are additions, absent from the
6//! golden fixtures. The tables are public domain (Ptolemy, Dorotheus, Lilly) and their
7//! sources are in `docs/dignities.md`.
8
9use serde::Serialize;
10
11use serde_json::Value;
12
13use crate::aspects::ASPECT_RULES;
14use crate::chart::Placement;
15use crate::error::EngineError;
16use crate::pyfloat;
17
18/// The seven planets, in Chaldean order.
19pub(crate) const SEVEN: [&str; 7] = [
20    "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon",
21];
22
23pub(crate) const SIGNS: [&str; 12] = [
24    "Aries",
25    "Taurus",
26    "Gemini",
27    "Cancer",
28    "Leo",
29    "Virgo",
30    "Libra",
31    "Scorpio",
32    "Sagittarius",
33    "Capricorn",
34    "Aquarius",
35    "Pisces",
36];
37
38/// Within 17 arc minutes of the Sun a planet is cazimi, in the heart of the Sun.
39pub(crate) const CAZIMI_ORB: f64 = 17.0 / 60.0;
40/// A planet this close to the Sun (degrees) is combust, unless cazimi.
41pub(crate) const COMBUST_ORB: f64 = 8.5;
42/// Within this distance from the Sun (degrees) a planet not combust is under the beams.
43const UNDER_BEAMS_ORB: f64 = 15.0;
44/// Orb (degrees) of an antiscion or contra-antiscion contact.
45const ANTISCIA_ORB: f64 = 1.0;
46/// Below this fraction of its mean motion a planet is stationary.
47const STATIONARY_FRACTION: f64 = 0.1;
48
49/// The doctrine of triplicities and bounds a chart is judged by.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum DignityScheme {
52    /// Lilly's *Christian Astrology*: two triplicity lords (Mars rules water by day and by
53    /// night) and the Ptolemaic terms as Lilly printed them.
54    #[default]
55    Lilly,
56    /// Dorotheus and the Hellenistic authors: three triplicity lords (by day, by night and
57    /// participating) and the Egyptian bounds.
58    Dorothean,
59}
60
61impl DignityScheme {
62    /// The scheme named by `code` ("lilly", "dorothean"); anything else is Lilly's.
63    pub fn from_code(code: &str) -> Self {
64        match code.trim().to_lowercase().as_str() {
65            "dorothean" | "dorotheus" | "egyptian" => DignityScheme::Dorothean,
66            _ => DignityScheme::Lilly,
67        }
68    }
69
70    /// The stable code of the scheme.
71    pub fn code(self) -> &'static str {
72        match self {
73            DignityScheme::Lilly => "lilly",
74            DignityScheme::Dorothean => "dorothean",
75        }
76    }
77}
78
79pub(crate) fn sign_index(sign: &str) -> Option<usize> {
80    SIGNS.iter().position(|s| *s == sign)
81}
82
83/// The domicile lord of a sign.
84pub(crate) fn traditional_ruler(sign: &str) -> Option<&'static str> {
85    Some(match sign {
86        "Aries" | "Scorpio" => "Mars",
87        "Taurus" | "Libra" => "Venus",
88        "Gemini" | "Virgo" => "Mercury",
89        "Cancer" => "Moon",
90        "Leo" => "Sun",
91        "Sagittarius" | "Pisces" => "Jupiter",
92        "Capricorn" | "Aquarius" => "Saturn",
93        _ => return None,
94    })
95}
96
97/// The planet exalted in a sign, with its degree of greatest exaltation.
98fn exaltation(sign: &str) -> Option<(&'static str, i64)> {
99    Some(match sign {
100        "Aries" => ("Sun", 19),
101        "Taurus" => ("Moon", 3),
102        "Cancer" => ("Jupiter", 15),
103        "Virgo" => ("Mercury", 15),
104        "Libra" => ("Saturn", 21),
105        "Capricorn" => ("Mars", 28),
106        "Pisces" => ("Venus", 27),
107        _ => return None,
108    })
109}
110
111/// Whether `planet` is in its domicile or exaltation in `sign`.
112pub(crate) fn is_dignified(planet: &str, sign: &str) -> bool {
113    traditional_ruler(sign) == Some(planet) || exaltation(sign).map(|(p, _)| p) == Some(planet)
114}
115
116/// Whether `planet` is in its detriment or fall in `sign`.
117pub(crate) fn is_debilitated(planet: &str, sign: &str) -> bool {
118    sign_index(sign).is_some_and(|i| is_dignified(planet, SIGNS[(i + 6) % 12]))
119}
120
121pub(crate) fn element(sign: &str) -> Option<&'static str> {
122    Some(match sign {
123        "Aries" | "Leo" | "Sagittarius" => "Fire",
124        "Taurus" | "Virgo" | "Capricorn" => "Earth",
125        "Gemini" | "Libra" | "Aquarius" => "Air",
126        "Cancer" | "Scorpio" | "Pisces" => "Water",
127        _ => return None,
128    })
129}
130
131/// The Dorothean triplicity lords of an element: by day, by night and participating.
132fn dorothean_triplicity(element: &str) -> Option<[&'static str; 3]> {
133    Some(match element {
134        "Fire" => ["Sun", "Jupiter", "Saturn"],
135        "Earth" => ["Venus", "Moon", "Mars"],
136        "Air" => ["Saturn", "Mercury", "Jupiter"],
137        "Water" => ["Venus", "Mars", "Moon"],
138        _ => return None,
139    })
140}
141
142/// The triplicity lord of an element by day or by night (Dorothean, as the horary
143/// radicality test takes it).
144pub(crate) fn triplicity_ruler(element: &str, is_day: bool) -> Option<&'static str> {
145    dorothean_triplicity(element).map(|[day, night, _]| if is_day { day } else { night })
146}
147
148/// Lilly's triplicity lords of an element: by day and by night.
149fn lilly_triplicity(element: &str) -> Option<[&'static str; 2]> {
150    Some(match element {
151        "Fire" => ["Sun", "Jupiter"],
152        "Earth" => ["Venus", "Moon"],
153        "Air" => ["Saturn", "Mercury"],
154        "Water" => ["Mars", "Mars"],
155        _ => return None,
156    })
157}
158
159/// Bounds of a sign: (lord, end degree), in order from 0°.
160type Bounds = [(&'static str, f64); 5];
161
162/// The Egyptian bounds (Ptolemy, Tetrabiblos I.20, after the Egyptians).
163const EGYPTIAN_BOUNDS: [Bounds; 12] = [
164    [
165        ("Jupiter", 6.0),
166        ("Venus", 12.0),
167        ("Mercury", 20.0),
168        ("Mars", 25.0),
169        ("Saturn", 30.0),
170    ],
171    [
172        ("Venus", 8.0),
173        ("Mercury", 14.0),
174        ("Jupiter", 22.0),
175        ("Saturn", 27.0),
176        ("Mars", 30.0),
177    ],
178    [
179        ("Mercury", 6.0),
180        ("Jupiter", 12.0),
181        ("Venus", 17.0),
182        ("Mars", 24.0),
183        ("Saturn", 30.0),
184    ],
185    [
186        ("Mars", 7.0),
187        ("Venus", 13.0),
188        ("Mercury", 19.0),
189        ("Jupiter", 26.0),
190        ("Saturn", 30.0),
191    ],
192    [
193        ("Jupiter", 6.0),
194        ("Venus", 11.0),
195        ("Saturn", 18.0),
196        ("Mercury", 24.0),
197        ("Mars", 30.0),
198    ],
199    [
200        ("Mercury", 7.0),
201        ("Venus", 17.0),
202        ("Jupiter", 21.0),
203        ("Mars", 28.0),
204        ("Saturn", 30.0),
205    ],
206    [
207        ("Saturn", 6.0),
208        ("Mercury", 14.0),
209        ("Jupiter", 21.0),
210        ("Venus", 28.0),
211        ("Mars", 30.0),
212    ],
213    [
214        ("Mars", 7.0),
215        ("Venus", 11.0),
216        ("Mercury", 19.0),
217        ("Jupiter", 24.0),
218        ("Saturn", 30.0),
219    ],
220    [
221        ("Jupiter", 12.0),
222        ("Venus", 17.0),
223        ("Mercury", 21.0),
224        ("Saturn", 26.0),
225        ("Mars", 30.0),
226    ],
227    [
228        ("Mercury", 7.0),
229        ("Jupiter", 14.0),
230        ("Venus", 22.0),
231        ("Saturn", 26.0),
232        ("Mars", 30.0),
233    ],
234    [
235        ("Mercury", 7.0),
236        ("Venus", 13.0),
237        ("Jupiter", 20.0),
238        ("Mars", 25.0),
239        ("Saturn", 30.0),
240    ],
241    [
242        ("Venus", 12.0),
243        ("Jupiter", 16.0),
244        ("Mercury", 19.0),
245        ("Mars", 28.0),
246        ("Saturn", 30.0),
247    ],
248];
249
250/// The Ptolemaic terms as Lilly printed them (Christian Astrology, 1647, p. 104).
251const LILLY_TERMS: [Bounds; 12] = [
252    [
253        ("Jupiter", 6.0),
254        ("Venus", 14.0),
255        ("Mercury", 21.0),
256        ("Mars", 26.0),
257        ("Saturn", 30.0),
258    ],
259    [
260        ("Venus", 8.0),
261        ("Mercury", 15.0),
262        ("Jupiter", 22.0),
263        ("Saturn", 26.0),
264        ("Mars", 30.0),
265    ],
266    [
267        ("Mercury", 7.0),
268        ("Jupiter", 14.0),
269        ("Venus", 21.0),
270        ("Saturn", 25.0),
271        ("Mars", 30.0),
272    ],
273    [
274        ("Mars", 6.0),
275        ("Jupiter", 13.0),
276        ("Mercury", 20.0),
277        ("Venus", 27.0),
278        ("Saturn", 30.0),
279    ],
280    [
281        ("Saturn", 6.0),
282        ("Mercury", 13.0),
283        ("Venus", 19.0),
284        ("Jupiter", 25.0),
285        ("Mars", 30.0),
286    ],
287    [
288        ("Mercury", 7.0),
289        ("Venus", 13.0),
290        ("Jupiter", 18.0),
291        ("Saturn", 24.0),
292        ("Mars", 30.0),
293    ],
294    [
295        ("Saturn", 6.0),
296        ("Venus", 11.0),
297        ("Jupiter", 19.0),
298        ("Mercury", 24.0),
299        ("Mars", 30.0),
300    ],
301    [
302        ("Mars", 6.0),
303        ("Jupiter", 14.0),
304        ("Venus", 21.0),
305        ("Mercury", 27.0),
306        ("Saturn", 30.0),
307    ],
308    [
309        ("Jupiter", 8.0),
310        ("Venus", 14.0),
311        ("Mercury", 19.0),
312        ("Saturn", 25.0),
313        ("Mars", 30.0),
314    ],
315    [
316        ("Venus", 6.0),
317        ("Mercury", 12.0),
318        ("Jupiter", 19.0),
319        ("Mars", 25.0),
320        ("Saturn", 30.0),
321    ],
322    [
323        ("Saturn", 6.0),
324        ("Mercury", 12.0),
325        ("Venus", 20.0),
326        ("Jupiter", 25.0),
327        ("Mars", 30.0),
328    ],
329    [
330        ("Venus", 8.0),
331        ("Jupiter", 14.0),
332        ("Mercury", 20.0),
333        ("Mars", 25.0),
334        ("Saturn", 30.0),
335    ],
336];
337
338/// The sign index (0 = Aries) and the degrees within it of a longitude.
339fn sign_and_degree(longitude: f64) -> (usize, f64) {
340    let lon = pyfloat::rem(longitude, 360.0);
341    let index = (pyfloat::floordiv(lon, 30.0) as usize).min(11);
342    (index, lon - index as f64 * 30.0)
343}
344
345/// The lord of the bound a longitude falls in.
346fn bound_lord(longitude: f64, scheme: DignityScheme) -> &'static str {
347    let (sign, degree) = sign_and_degree(longitude);
348    let table = match scheme {
349        DignityScheme::Lilly => &LILLY_TERMS,
350        DignityScheme::Dorothean => &EGYPTIAN_BOUNDS,
351    };
352    table[sign]
353        .iter()
354        .find(|(_, end)| degree < *end)
355        .map_or(table[sign][4].0, |(lord, _)| lord)
356}
357
358/// The lord of the face (decan) a longitude falls in: Chaldean order from Mars at 0° Aries.
359fn face_lord(longitude: f64) -> &'static str {
360    let (sign, degree) = sign_and_degree(longitude);
361    let decan = sign * 3 + ((degree / 10.0) as usize).min(2);
362    // Mars is third in the Chaldean order (Saturn, Jupiter, Mars).
363    SEVEN[(decan + 2) % 7]
364}
365
366/// The lords of each essential dignity at a longitude.
367#[derive(Debug, Clone, PartialEq, Serialize)]
368pub struct DignityLords {
369    /// Lord of the sign.
370    pub domicile: &'static str,
371    /// Planet exalted in the sign, if any.
372    pub exaltation: Option<&'static str>,
373    /// Triplicity lords: by day and by night (Lilly), plus the participating one
374    /// (Dorothean).
375    pub triplicity: Vec<&'static str>,
376    /// Lord of the bound (term).
377    pub bound: &'static str,
378    /// Lord of the face (decan).
379    pub face: &'static str,
380}
381
382/// The lords of each dignity at `longitude`.
383fn lords_at(longitude: f64, scheme: DignityScheme) -> DignityLords {
384    let (sign, _) = sign_and_degree(longitude);
385    let sign = SIGNS[sign];
386    let el = element(sign).unwrap_or("Fire");
387    DignityLords {
388        domicile: traditional_ruler(sign).unwrap_or("Mars"),
389        exaltation: exaltation(sign).map(|(planet, _)| planet),
390        triplicity: match scheme {
391            DignityScheme::Lilly => lilly_triplicity(el).map(|t| t.to_vec()),
392            DignityScheme::Dorothean => dorothean_triplicity(el).map(|t| t.to_vec()),
393        }
394        .unwrap_or_default(),
395        bound: bound_lord(longitude, scheme),
396        face: face_lord(longitude),
397    }
398}
399
400/// A planet's essential dignities and debilities where it stands.
401#[derive(Debug, Clone, PartialEq, Serialize)]
402pub struct EssentialDignity {
403    /// In its own sign.
404    pub domicile: bool,
405    /// In the sign of its exaltation.
406    pub exaltation: bool,
407    /// Lord of the triplicity (Lilly: of the chart's sect; Dorothean: of the sect or
408    /// participating).
409    pub triplicity: bool,
410    /// In its own bound.
411    pub bound: bool,
412    /// In its own face.
413    pub face: bool,
414    /// In the sign opposite its domicile.
415    pub detriment: bool,
416    /// In the sign opposite its exaltation.
417    pub fall: bool,
418    /// Without any of the five dignities.
419    pub peregrine: bool,
420    /// Lilly's points: domicile 5, exaltation 4, triplicity 3, bound 2, face 1; detriment
421    /// −5, fall −4, peregrine −5.
422    pub score: i64,
423    /// The lords of the place, whatever the planet.
424    pub lords: DignityLords,
425}
426
427/// The condition of one of the seven planets.
428#[derive(Debug, Clone, PartialEq, Serialize)]
429pub struct Condition {
430    /// Its essential dignities.
431    pub essential: EssentialDignity,
432    /// "diurnal" or "nocturnal": the Sun, Jupiter and Saturn are diurnal; the Moon, Venus and
433    /// Mars nocturnal; Mercury is diurnal when oriental, nocturnal when occidental.
434    pub sect: &'static str,
435    /// Whether its sect is the chart's.
436    pub in_sect: bool,
437    /// Whether it is above the horizon.
438    pub above_horizon: bool,
439    /// "cazimi", "combust" or "under_beams" when close to the Sun; `None` otherwise (and for
440    /// the Sun).
441    pub solar_phase: Option<&'static str>,
442    /// "oriental" (rising before the Sun) or "occidental"; `None` for the luminaries.
443    pub orientality: Option<&'static str>,
444    /// "fast", "slow" or "stationary", against the planet's mean daily motion.
445    pub motion: &'static str,
446}
447
448/// Mean daily motion in longitude, degrees (Lilly's values).
449fn mean_motion(planet: &str) -> f64 {
450    match planet {
451        "Saturn" => 2.0 / 60.0 + 1.0 / 3600.0,
452        "Jupiter" => 4.0 / 60.0 + 59.0 / 3600.0,
453        "Mars" => 31.0 / 60.0 + 27.0 / 3600.0,
454        "Moon" => 13.0 + 10.0 / 60.0 + 36.0 / 3600.0,
455        // The Sun, Venus and Mercury share the Sun's mean motion.
456        _ => 59.0 / 60.0 + 8.0 / 3600.0,
457    }
458}
459
460/// Angular distance between two longitudes, 0-180 degrees.
461pub(crate) fn separation(a: f64, b: f64) -> f64 {
462    let d = pyfloat::rem((a - b).abs(), 360.0);
463    if d > 180.0 {
464        360.0 - d
465    } else {
466        d
467    }
468}
469
470/// Whether a longitude is above the horizon of an Ascendant.
471fn is_above_horizon(longitude: f64, ascendant: f64) -> bool {
472    pyfloat::rem(longitude - ascendant, 360.0) >= 180.0
473}
474
475/// A planet's phase to the Sun: cazimi, combust or under the beams.
476pub(crate) fn solar_phase(planet: &str, longitude: f64, sun: f64) -> Option<&'static str> {
477    if planet == "Sun" {
478        return None;
479    }
480    let d = separation(longitude, sun);
481    if d <= CAZIMI_ORB {
482        Some("cazimi")
483    } else if d < COMBUST_ORB {
484        Some("combust")
485    } else if d < UNDER_BEAMS_ORB {
486        Some("under_beams")
487    } else {
488        None
489    }
490}
491
492/// Oriental when it rises before the Sun: behind it in the zodiac, by less than 180°.
493fn is_oriental(longitude: f64, sun: f64) -> bool {
494    let behind = pyfloat::rem(sun - longitude, 360.0);
495    behind > 0.0 && behind < 180.0
496}
497
498/// Whether the chart is diurnal: the Sun above the horizon.
499pub(crate) fn is_diurnal(sun: f64, ascendant: f64) -> bool {
500    is_above_horizon(sun, ascendant)
501}
502
503/// The essential dignities of `planet` at `longitude` in a day or night chart.
504pub(crate) fn essential_dignity(
505    planet: &str,
506    longitude: f64,
507    diurnal: bool,
508    scheme: DignityScheme,
509) -> EssentialDignity {
510    let lords = lords_at(longitude, scheme);
511    let (sign, _) = sign_and_degree(longitude);
512    let opposite = SIGNS[(sign + 6) % 12];
513
514    let domicile = lords.domicile == planet;
515    let exalted = lords.exaltation == Some(planet);
516    let triplicity = match (scheme, lords.triplicity.as_slice()) {
517        (DignityScheme::Lilly, [day, night]) => planet == if diurnal { *day } else { *night },
518        (DignityScheme::Dorothean, [day, night, participating]) => {
519            planet == if diurnal { *day } else { *night } || planet == *participating
520        }
521        _ => false,
522    };
523    let bound = lords.bound == planet;
524    let face = lords.face == planet;
525    let detriment = traditional_ruler(opposite) == Some(planet);
526    let fall = exaltation(opposite).map(|(p, _)| p) == Some(planet);
527    let peregrine = !(domicile || exalted || triplicity || bound || face);
528
529    let score = 5 * domicile as i64
530        + 4 * exalted as i64
531        + 3 * triplicity as i64
532        + 2 * bound as i64
533        + face as i64
534        - 5 * detriment as i64
535        - 4 * fall as i64
536        - 5 * peregrine as i64;
537
538    EssentialDignity {
539        domicile,
540        exaltation: exalted,
541        triplicity,
542        bound,
543        face,
544        detriment,
545        fall,
546        peregrine,
547        score,
548        lords,
549    }
550}
551
552/// What judging a planet needs: its name, longitude and speed.
553#[derive(Debug, Clone, Copy)]
554pub(crate) struct Body {
555    pub name: &'static str,
556    pub longitude: f64,
557    pub speed: f64,
558}
559
560impl From<&Placement> for Body {
561    fn from(p: &Placement) -> Self {
562        Body {
563            name: p.name,
564            longitude: p.ecliptic_longitude,
565            speed: p.speed,
566        }
567    }
568}
569
570/// The condition of a planet of the seven; `None` for any other point.
571pub(crate) fn condition(
572    planet: Body,
573    sun: f64,
574    ascendant: f64,
575    scheme: DignityScheme,
576) -> Option<Condition> {
577    if !SEVEN.contains(&planet.name) {
578        return None;
579    }
580    let lon = planet.longitude;
581    let diurnal = is_diurnal(sun, ascendant);
582    let orientality = match planet.name {
583        "Sun" | "Moon" => None,
584        _ if is_oriental(lon, sun) => Some("oriental"),
585        _ => Some("occidental"),
586    };
587    let sect = match planet.name {
588        "Sun" | "Jupiter" | "Saturn" => "diurnal",
589        "Mercury" if orientality == Some("oriental") => "diurnal",
590        _ => "nocturnal",
591    };
592    let mean = mean_motion(planet.name);
593    let speed = planet.speed.abs();
594    let motion = if !matches!(planet.name, "Sun" | "Moon") && speed < mean * STATIONARY_FRACTION {
595        "stationary"
596    } else if speed >= mean {
597        "fast"
598    } else {
599        "slow"
600    };
601
602    Some(Condition {
603        essential: essential_dignity(planet.name, lon, diurnal, scheme),
604        sect,
605        in_sect: (sect == "diurnal") == diurnal,
606        above_horizon: is_above_horizon(lon, ascendant),
607        solar_phase: solar_phase(planet.name, lon, sun),
608        orientality,
609        motion,
610    })
611}
612
613/// One planet received by another: it stands in a dignity of the receiver.
614#[derive(Debug, Clone, PartialEq, Serialize)]
615pub struct Reception {
616    /// The planet received.
617    pub planet: &'static str,
618    /// The planet receiving it, lord of one or more dignities where it stands.
619    pub receiver: &'static str,
620    /// The dignities of the receiver at the planet's place: "domicile", "exaltation",
621    /// "triplicity", "bound", "face".
622    pub dignities: Vec<&'static str>,
623    /// Whether the receiver is received in turn by domicile or exaltation, and the planet
624    /// receives by domicile or exaltation.
625    pub mutual: bool,
626    /// The aspect between them, if any.
627    pub aspect: Option<&'static str>,
628}
629
630/// The dignities `receiver` holds at `longitude`.
631fn dignities_of(receiver: &str, longitude: f64, scheme: DignityScheme) -> Vec<&'static str> {
632    let lords = lords_at(longitude, scheme);
633    let mut out = Vec::new();
634    if lords.domicile == receiver {
635        out.push("domicile");
636    }
637    if lords.exaltation == Some(receiver) {
638        out.push("exaltation");
639    }
640    if lords.triplicity.contains(&receiver) {
641        out.push("triplicity");
642    }
643    if lords.bound == receiver {
644        out.push("bound");
645    }
646    if lords.face == receiver {
647        out.push("face");
648    }
649    out
650}
651
652fn is_strong(dignities: &[&str]) -> bool {
653    dignities
654        .iter()
655        .any(|d| matches!(*d, "domicile" | "exaltation"))
656}
657
658/// Receptions among the seven planets: those joined by an aspect of the chart, by any
659/// dignity; and the mutual receptions by domicile or exaltation, aspected or not.
660pub(crate) fn receptions(
661    planets: &[Body],
662    aspect_between: impl Fn(&str, &str) -> Option<&'static str>,
663    scheme: DignityScheme,
664) -> Vec<Reception> {
665    let seven: Vec<&Body> = SEVEN
666        .iter()
667        .filter_map(|name| planets.iter().find(|p| p.name == *name))
668        .collect();
669    let mut out = Vec::new();
670    for planet in &seven {
671        for receiver in &seven {
672            if planet.name == receiver.name {
673                continue;
674            }
675            let held = dignities_of(receiver.name, planet.longitude, scheme);
676            if held.is_empty() {
677                continue;
678            }
679            let back = dignities_of(planet.name, receiver.longitude, scheme);
680            let mutual = is_strong(&held) && is_strong(&back);
681            let aspect = aspect_between(planet.name, receiver.name);
682            if aspect.is_some() || mutual {
683                out.push(Reception {
684                    planet: planet.name,
685                    receiver: receiver.name,
686                    dignities: held,
687                    mutual,
688                    aspect,
689                });
690            }
691        }
692    }
693    out
694}
695
696/// The condition of one planet, by name.
697#[derive(Debug, Clone, PartialEq, Serialize)]
698pub struct PlanetCondition {
699    /// One of the seven planets.
700    pub name: &'static str,
701    /// Its condition under the scheme.
702    pub condition: Condition,
703}
704
705/// A chart's dignities judged again under a scheme: what [`chart_dignities`] returns.
706#[derive(Debug, Clone, PartialEq, Serialize)]
707pub struct ChartDignities {
708    /// The scheme used: "lilly" or "dorothean".
709    pub dignity_scheme: &'static str,
710    /// "diurnal" or "nocturnal".
711    pub sect: &'static str,
712    /// The condition of each of the seven planets in the chart.
713    pub conditions: Vec<PlanetCondition>,
714    /// Receptions among the seven planets.
715    pub receptions: Vec<Reception>,
716}
717
718/// The static name of a chart point given as text (the seven planets and the Ascendant).
719fn static_name(name: &str) -> Option<&'static str> {
720    SEVEN
721        .iter()
722        .copied()
723        .chain(["Ascendant"])
724        .find(|n| *n == name)
725}
726
727/// The static name of a Ptolemaic or minor aspect given as text.
728fn static_aspect(name: &str) -> Option<&'static str> {
729    ASPECT_RULES.iter().map(|r| r.name).find(|n| *n == name)
730}
731
732/// Judge the dignities of an already computed chart (its JSON, as [`calculate_chart`](crate::calculate_chart)
733/// returns it: `planets` with `name`, `ecliptic_longitude` and `speed`, and `aspects`) under
734/// `scheme` ("lilly" or "dorothean"). No kernel is needed: a chart stored with one scheme can
735/// be shown with the other.
736pub fn chart_dignities(chart: &Value, scheme: &str) -> Result<ChartDignities, EngineError> {
737    let scheme = DignityScheme::from_code(scheme);
738    let invalid = |what: &str| EngineError::InvalidInput(format!("chart_dignities: {what}"));
739    let planets = chart
740        .get("planets")
741        .and_then(Value::as_array)
742        .ok_or_else(|| invalid("the chart has no planets"))?;
743    let bodies: Vec<Body> = planets
744        .iter()
745        .filter_map(|p| {
746            Some(Body {
747                name: static_name(p.get("name")?.as_str()?)?,
748                longitude: p.get("ecliptic_longitude")?.as_f64()?,
749                speed: p.get("speed").and_then(Value::as_f64).unwrap_or(0.0),
750            })
751        })
752        .collect();
753    let at = |name: &str| bodies.iter().find(|b| b.name == name).map(|b| b.longitude);
754    let (sun, asc) = at("Sun")
755        .zip(at("Ascendant"))
756        .ok_or_else(|| invalid("the chart needs the Sun and the Ascendant"))?;
757
758    let aspects: Vec<(String, String, &'static str)> = chart
759        .get("aspects")
760        .and_then(Value::as_array)
761        .map(|list| {
762            list.iter()
763                .filter_map(|a| {
764                    Some((
765                        a.get("body1")?.as_str()?.to_string(),
766                        a.get("body2")?.as_str()?.to_string(),
767                        static_aspect(a.get("aspect_type")?.as_str()?)?,
768                    ))
769                })
770                .collect()
771        })
772        .unwrap_or_default();
773    let aspect_between = |a: &str, b: &str| {
774        aspects
775            .iter()
776            .find(|(x, y, _)| (x == a && y == b) || (x == b && y == a))
777            .map(|(_, _, kind)| *kind)
778    };
779
780    Ok(ChartDignities {
781        dignity_scheme: scheme.code(),
782        sect: if is_diurnal(sun, asc) {
783            "diurnal"
784        } else {
785            "nocturnal"
786        },
787        conditions: bodies
788            .iter()
789            .filter_map(|b| {
790                condition(*b, sun, asc, scheme).map(|condition| PlanetCondition {
791                    name: b.name,
792                    condition,
793                })
794            })
795            .collect(),
796        receptions: receptions(&bodies, aspect_between, scheme),
797    })
798}
799
800/// Two points joined by antiscion (mirrored across the solstitial axis, Cancer–Capricorn)
801/// or contra-antiscion (across the equinoctial axis, Aries–Libra).
802#[derive(Debug, Clone, PartialEq, Serialize)]
803pub struct AntisciaContact {
804    /// First point.
805    pub body1: &'static str,
806    /// Second point.
807    pub body2: &'static str,
808    /// "antiscion" or "contra_antiscion".
809    pub kind: &'static str,
810    /// Distance from exactness, degrees (rounded to 0.01).
811    pub orb: f64,
812}
813
814/// The antiscion of a longitude: its mirror across 0° Cancer / 0° Capricorn.
815pub(crate) fn antiscion(longitude: f64) -> f64 {
816    pyfloat::rem(180.0 - longitude, 360.0)
817}
818
819/// The contra-antiscion of a longitude: its mirror across 0° Aries / 0° Libra.
820pub(crate) fn contra_antiscion(longitude: f64) -> f64 {
821    pyfloat::rem(360.0 - longitude, 360.0)
822}
823
824/// Antiscia and contra-antiscia among the seven planets and the angles.
825pub(crate) fn antiscia(planets: &[Placement]) -> Vec<AntisciaContact> {
826    let points: Vec<&Placement> = planets
827        .iter()
828        .filter(|p| SEVEN.contains(&p.name) || matches!(p.name, "Ascendant" | "Midheaven"))
829        .collect();
830    let mut out = Vec::new();
831    for (i, a) in points.iter().enumerate() {
832        for b in &points[i + 1..] {
833            for (kind, mirror) in [
834                ("antiscion", antiscion(a.ecliptic_longitude)),
835                ("contra_antiscion", contra_antiscion(a.ecliptic_longitude)),
836            ] {
837                let orb = separation(mirror, b.ecliptic_longitude);
838                if orb <= ANTISCIA_ORB {
839                    out.push(AntisciaContact {
840                        body1: a.name,
841                        body2: b.name,
842                        kind,
843                        orb: pyfloat::round(orb, 2),
844                    });
845                }
846            }
847        }
848    }
849    out
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    fn lon(sign: &str, degree: f64) -> f64 {
857        sign_index(sign).unwrap() as f64 * 30.0 + degree
858    }
859
860    #[test]
861    fn bound_tables_partition_every_sign() {
862        for table in [&EGYPTIAN_BOUNDS, &LILLY_TERMS] {
863            for bounds in table.iter() {
864                let mut lords: Vec<&str> = bounds.iter().map(|(l, _)| *l).collect();
865                lords.sort_unstable();
866                assert_eq!(lords, ["Jupiter", "Mars", "Mercury", "Saturn", "Venus"]);
867                assert!(bounds.windows(2).all(|w| w[0].1 < w[1].1));
868                assert_eq!(bounds[4].1, 30.0);
869            }
870        }
871    }
872
873    #[test]
874    fn egyptian_bounds_give_each_planet_its_years() {
875        // The minor years of the planets: Saturn 57, Jupiter 79, Mars 66, Venus 82,
876        // Mercury 76 (Valens, Anthology III.13).
877        let mut total = std::collections::HashMap::new();
878        for bounds in EGYPTIAN_BOUNDS {
879            let mut start = 0.0;
880            for (lord, end) in bounds {
881                *total.entry(lord).or_insert(0.0) += end - start;
882                start = end;
883            }
884        }
885        assert_eq!(total["Saturn"], 57.0);
886        assert_eq!(total["Jupiter"], 79.0);
887        assert_eq!(total["Mars"], 66.0);
888        assert_eq!(total["Venus"], 82.0);
889        assert_eq!(total["Mercury"], 76.0);
890    }
891
892    #[test]
893    fn faces_follow_the_chaldean_order_from_mars() {
894        assert_eq!(face_lord(lon("Aries", 0.0)), "Mars");
895        assert_eq!(face_lord(lon("Aries", 15.0)), "Sun");
896        assert_eq!(face_lord(lon("Aries", 29.9)), "Venus");
897        assert_eq!(face_lord(lon("Taurus", 5.0)), "Mercury");
898        assert_eq!(face_lord(lon("Leo", 0.0)), "Saturn");
899        assert_eq!(face_lord(lon("Pisces", 25.0)), "Mars");
900    }
901
902    #[test]
903    fn bounds_by_scheme() {
904        // 10° Aries: Venus in Lilly's terms, Venus in the Egyptian bounds;
905        // 13° Aries: Venus (Lilly) but Mercury (Egyptian).
906        assert_eq!(
907            bound_lord(lon("Aries", 10.0), DignityScheme::Lilly),
908            "Venus"
909        );
910        assert_eq!(
911            bound_lord(lon("Aries", 13.0), DignityScheme::Lilly),
912            "Venus"
913        );
914        assert_eq!(
915            bound_lord(lon("Aries", 13.0), DignityScheme::Dorothean),
916            "Mercury"
917        );
918        assert_eq!(
919            bound_lord(lon("Pisces", 29.99), DignityScheme::Lilly),
920            "Saturn"
921        );
922        assert_eq!(bound_lord(lon("Leo", 0.0), DignityScheme::Lilly), "Saturn");
923        assert_eq!(
924            bound_lord(lon("Leo", 0.0), DignityScheme::Dorothean),
925            "Jupiter"
926        );
927    }
928
929    #[test]
930    fn lillys_points() {
931        // The Sun at 19° Aries by day: exaltation 4, triplicity 3, face (Sun 10-20) 1.
932        let sun = essential_dignity("Sun", lon("Aries", 19.0), true, DignityScheme::Lilly);
933        assert!(sun.exaltation && sun.triplicity && sun.face && !sun.domicile);
934        assert_eq!(sun.score, 8);
935        // Saturn at 5° Aries: fall, and nothing else: −4 −5.
936        let saturn = essential_dignity("Saturn", lon("Aries", 5.0), true, DignityScheme::Lilly);
937        assert!(saturn.fall && saturn.peregrine && !saturn.detriment);
938        assert_eq!(saturn.score, -9);
939        // Mars in water is a triplicity lord by night too, for Lilly only.
940        let mars = essential_dignity("Mars", lon("Pisces", 2.0), true, DignityScheme::Lilly);
941        assert!(mars.triplicity);
942        let mars = essential_dignity("Mars", lon("Pisces", 2.0), true, DignityScheme::Dorothean);
943        assert!(!mars.triplicity);
944        // Venus in Libra: domicile 5, plus her term at 6-11 (Lilly).
945        let venus = essential_dignity("Venus", lon("Libra", 8.0), false, DignityScheme::Lilly);
946        assert!(venus.domicile && venus.bound);
947        assert_eq!(venus.lords.triplicity, ["Saturn", "Mercury"]);
948        // The Moon in Capricorn by night: detriment, but the night lord of earth (Dorothean).
949        let moon = essential_dignity(
950            "Moon",
951            lon("Capricorn", 1.0),
952            false,
953            DignityScheme::Dorothean,
954        );
955        assert!(moon.detriment && moon.triplicity && !moon.peregrine);
956        let moon = essential_dignity(
957            "Moon",
958            lon("Capricorn", 1.0),
959            true,
960            DignityScheme::Dorothean,
961        );
962        assert!(!moon.triplicity);
963        assert_eq!(moon.lords.triplicity, ["Venus", "Moon", "Mars"]);
964    }
965
966    #[test]
967    fn solar_phases() {
968        assert_eq!(solar_phase("Mercury", 100.1, 100.0), Some("cazimi"));
969        assert_eq!(solar_phase("Mercury", 105.0, 100.0), Some("combust"));
970        assert_eq!(solar_phase("Venus", 88.0, 100.0), Some("under_beams"));
971        assert_eq!(solar_phase("Mars", 130.0, 100.0), None);
972        assert_eq!(solar_phase("Sun", 100.0, 100.0), None);
973        assert_eq!(solar_phase("Moon", 355.0, 3.0), Some("combust"));
974    }
975
976    #[test]
977    fn mirrors() {
978        // 10° Gemini mirrors to 20° Cancer (antiscion) and 20° Capricorn (contra).
979        assert!((antiscion(lon("Gemini", 10.0)) - lon("Cancer", 20.0)).abs() < 1e-9);
980        assert!((contra_antiscion(lon("Gemini", 10.0)) - lon("Capricorn", 20.0)).abs() < 1e-9);
981        assert!((antiscion(lon("Capricorn", 5.0)) - lon("Sagittarius", 25.0)).abs() < 1e-9);
982    }
983
984    #[test]
985    fn sect_and_orientality() {
986        // Ascendant at 0° Cancer puts the Midheaven near 0° Aries: a Sun there culminates.
987        assert!(is_diurnal(0.0, 90.0));
988        // Ascendant at 0° Capricorn puts 0° Aries on the lower heaven, below the horizon.
989        assert!(!is_diurnal(0.0, 270.0));
990        assert!(is_oriental(350.0, 0.0));
991        assert!(!is_oriental(10.0, 0.0));
992    }
993}