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/// Whether a longitude is above the horizon of an Ascendant.
461fn is_above_horizon(longitude: f64, ascendant: f64) -> bool {
462    pyfloat::rem(longitude - ascendant, 360.0) >= 180.0
463}
464
465/// A planet's phase to the Sun: cazimi, combust or under the beams.
466pub(crate) fn solar_phase(planet: &str, longitude: f64, sun: f64) -> Option<&'static str> {
467    if planet == "Sun" {
468        return None;
469    }
470    let d = pyfloat::separation(longitude, sun);
471    if d <= CAZIMI_ORB {
472        Some("cazimi")
473    } else if d < COMBUST_ORB {
474        Some("combust")
475    } else if d < UNDER_BEAMS_ORB {
476        Some("under_beams")
477    } else {
478        None
479    }
480}
481
482/// Oriental when it rises before the Sun: behind it in the zodiac, by less than 180°.
483fn is_oriental(longitude: f64, sun: f64) -> bool {
484    let behind = pyfloat::rem(sun - longitude, 360.0);
485    behind > 0.0 && behind < 180.0
486}
487
488/// Whether the chart is diurnal: the Sun above the horizon.
489pub(crate) fn is_diurnal(sun: f64, ascendant: f64) -> bool {
490    is_above_horizon(sun, ascendant)
491}
492
493/// The essential dignities of `planet` at `longitude` in a day or night chart.
494pub(crate) fn essential_dignity(
495    planet: &str,
496    longitude: f64,
497    diurnal: bool,
498    scheme: DignityScheme,
499) -> EssentialDignity {
500    let lords = lords_at(longitude, scheme);
501    let (sign, _) = sign_and_degree(longitude);
502    let opposite = SIGNS[(sign + 6) % 12];
503
504    let domicile = lords.domicile == planet;
505    let exalted = lords.exaltation == Some(planet);
506    let triplicity = match (scheme, lords.triplicity.as_slice()) {
507        (DignityScheme::Lilly, [day, night]) => planet == if diurnal { *day } else { *night },
508        (DignityScheme::Dorothean, [day, night, participating]) => {
509            planet == if diurnal { *day } else { *night } || planet == *participating
510        }
511        _ => false,
512    };
513    let bound = lords.bound == planet;
514    let face = lords.face == planet;
515    let detriment = traditional_ruler(opposite) == Some(planet);
516    let fall = exaltation(opposite).map(|(p, _)| p) == Some(planet);
517    let peregrine = !(domicile || exalted || triplicity || bound || face);
518
519    let score = 5 * domicile as i64
520        + 4 * exalted as i64
521        + 3 * triplicity as i64
522        + 2 * bound as i64
523        + face as i64
524        - 5 * detriment as i64
525        - 4 * fall as i64
526        - 5 * peregrine as i64;
527
528    EssentialDignity {
529        domicile,
530        exaltation: exalted,
531        triplicity,
532        bound,
533        face,
534        detriment,
535        fall,
536        peregrine,
537        score,
538        lords,
539    }
540}
541
542/// What judging a planet needs: its name, longitude and speed.
543#[derive(Debug, Clone, Copy)]
544pub(crate) struct Body {
545    pub name: &'static str,
546    pub longitude: f64,
547    pub speed: f64,
548}
549
550impl From<&Placement> for Body {
551    fn from(p: &Placement) -> Self {
552        Body {
553            name: p.name,
554            longitude: p.ecliptic_longitude,
555            speed: p.speed,
556        }
557    }
558}
559
560/// The condition of a planet of the seven; `None` for any other point.
561pub(crate) fn condition(
562    planet: Body,
563    sun: f64,
564    ascendant: f64,
565    scheme: DignityScheme,
566) -> Option<Condition> {
567    if !SEVEN.contains(&planet.name) {
568        return None;
569    }
570    let lon = planet.longitude;
571    let diurnal = is_diurnal(sun, ascendant);
572    let orientality = match planet.name {
573        "Sun" | "Moon" => None,
574        _ if is_oriental(lon, sun) => Some("oriental"),
575        _ => Some("occidental"),
576    };
577    let sect = match planet.name {
578        "Sun" | "Jupiter" | "Saturn" => "diurnal",
579        "Mercury" if orientality == Some("oriental") => "diurnal",
580        _ => "nocturnal",
581    };
582    let mean = mean_motion(planet.name);
583    let speed = planet.speed.abs();
584    let motion = if !matches!(planet.name, "Sun" | "Moon") && speed < mean * STATIONARY_FRACTION {
585        "stationary"
586    } else if speed >= mean {
587        "fast"
588    } else {
589        "slow"
590    };
591
592    Some(Condition {
593        essential: essential_dignity(planet.name, lon, diurnal, scheme),
594        sect,
595        in_sect: (sect == "diurnal") == diurnal,
596        above_horizon: is_above_horizon(lon, ascendant),
597        solar_phase: solar_phase(planet.name, lon, sun),
598        orientality,
599        motion,
600    })
601}
602
603/// One planet received by another: it stands in a dignity of the receiver.
604#[derive(Debug, Clone, PartialEq, Serialize)]
605pub struct Reception {
606    /// The planet received.
607    pub planet: &'static str,
608    /// The planet receiving it, lord of one or more dignities where it stands.
609    pub receiver: &'static str,
610    /// The dignities of the receiver at the planet's place: "domicile", "exaltation",
611    /// "triplicity", "bound", "face".
612    pub dignities: Vec<&'static str>,
613    /// Whether the receiver is received in turn by domicile or exaltation, and the planet
614    /// receives by domicile or exaltation.
615    pub mutual: bool,
616    /// The aspect between them, if any.
617    pub aspect: Option<&'static str>,
618}
619
620/// The dignities `receiver` holds at `longitude`.
621fn dignities_of(receiver: &str, longitude: f64, scheme: DignityScheme) -> Vec<&'static str> {
622    let lords = lords_at(longitude, scheme);
623    let mut out = Vec::new();
624    if lords.domicile == receiver {
625        out.push("domicile");
626    }
627    if lords.exaltation == Some(receiver) {
628        out.push("exaltation");
629    }
630    if lords.triplicity.contains(&receiver) {
631        out.push("triplicity");
632    }
633    if lords.bound == receiver {
634        out.push("bound");
635    }
636    if lords.face == receiver {
637        out.push("face");
638    }
639    out
640}
641
642fn is_strong(dignities: &[&str]) -> bool {
643    dignities
644        .iter()
645        .any(|d| matches!(*d, "domicile" | "exaltation"))
646}
647
648/// Receptions among the seven planets: those joined by an aspect of the chart, by any
649/// dignity; and the mutual receptions by domicile or exaltation, aspected or not.
650pub(crate) fn receptions(
651    planets: &[Body],
652    aspect_between: impl Fn(&str, &str) -> Option<&'static str>,
653    scheme: DignityScheme,
654) -> Vec<Reception> {
655    let seven: Vec<&Body> = SEVEN
656        .iter()
657        .filter_map(|name| planets.iter().find(|p| p.name == *name))
658        .collect();
659    let mut out = Vec::new();
660    for planet in &seven {
661        for receiver in &seven {
662            if planet.name == receiver.name {
663                continue;
664            }
665            let held = dignities_of(receiver.name, planet.longitude, scheme);
666            if held.is_empty() {
667                continue;
668            }
669            let back = dignities_of(planet.name, receiver.longitude, scheme);
670            let mutual = is_strong(&held) && is_strong(&back);
671            let aspect = aspect_between(planet.name, receiver.name);
672            if aspect.is_some() || mutual {
673                out.push(Reception {
674                    planet: planet.name,
675                    receiver: receiver.name,
676                    dignities: held,
677                    mutual,
678                    aspect,
679                });
680            }
681        }
682    }
683    out
684}
685
686/// The condition of one planet, by name.
687#[derive(Debug, Clone, PartialEq, Serialize)]
688pub struct PlanetCondition {
689    /// One of the seven planets.
690    pub name: &'static str,
691    /// Its condition under the scheme.
692    pub condition: Condition,
693}
694
695/// A chart's dignities judged again under a scheme: what [`chart_dignities`] returns.
696#[derive(Debug, Clone, PartialEq, Serialize)]
697pub struct ChartDignities {
698    /// The scheme used: "lilly" or "dorothean".
699    pub dignity_scheme: &'static str,
700    /// "diurnal" or "nocturnal".
701    pub sect: &'static str,
702    /// The condition of each of the seven planets in the chart.
703    pub conditions: Vec<PlanetCondition>,
704    /// Receptions among the seven planets.
705    pub receptions: Vec<Reception>,
706}
707
708/// The static name of a chart point given as text (the seven planets and the Ascendant).
709fn static_name(name: &str) -> Option<&'static str> {
710    SEVEN
711        .iter()
712        .copied()
713        .chain(["Ascendant"])
714        .find(|n| *n == name)
715}
716
717/// The static name of a Ptolemaic or minor aspect given as text.
718fn static_aspect(name: &str) -> Option<&'static str> {
719    ASPECT_RULES.iter().map(|r| r.name).find(|n| *n == name)
720}
721
722/// Judge the dignities of an already computed chart (its JSON, as [`calculate_chart`](crate::calculate_chart)
723/// returns it: `planets` with `name`, `ecliptic_longitude` and `speed`, and `aspects`) under
724/// `scheme` ("lilly" or "dorothean"). No kernel is needed: a chart stored with one scheme can
725/// be shown with the other.
726pub fn chart_dignities(chart: &Value, scheme: &str) -> Result<ChartDignities, EngineError> {
727    let scheme = DignityScheme::from_code(scheme);
728    let invalid = |what: &str| EngineError::InvalidInput(format!("chart_dignities: {what}"));
729    let planets = chart
730        .get("planets")
731        .and_then(Value::as_array)
732        .ok_or_else(|| invalid("the chart has no planets"))?;
733    let bodies: Vec<Body> = planets
734        .iter()
735        .filter_map(|p| {
736            Some(Body {
737                name: static_name(p.get("name")?.as_str()?)?,
738                longitude: p.get("ecliptic_longitude")?.as_f64()?,
739                speed: p.get("speed").and_then(Value::as_f64).unwrap_or(0.0),
740            })
741        })
742        .collect();
743    let at = |name: &str| bodies.iter().find(|b| b.name == name).map(|b| b.longitude);
744    let (sun, asc) = at("Sun")
745        .zip(at("Ascendant"))
746        .ok_or_else(|| invalid("the chart needs the Sun and the Ascendant"))?;
747
748    let aspects: Vec<(String, String, &'static str)> = chart
749        .get("aspects")
750        .and_then(Value::as_array)
751        .map(|list| {
752            list.iter()
753                .filter_map(|a| {
754                    Some((
755                        a.get("body1")?.as_str()?.to_string(),
756                        a.get("body2")?.as_str()?.to_string(),
757                        static_aspect(a.get("aspect_type")?.as_str()?)?,
758                    ))
759                })
760                .collect()
761        })
762        .unwrap_or_default();
763    let aspect_between = |a: &str, b: &str| {
764        aspects
765            .iter()
766            .find(|(x, y, _)| (x == a && y == b) || (x == b && y == a))
767            .map(|(_, _, kind)| *kind)
768    };
769
770    Ok(ChartDignities {
771        dignity_scheme: scheme.code(),
772        sect: if is_diurnal(sun, asc) {
773            "diurnal"
774        } else {
775            "nocturnal"
776        },
777        conditions: bodies
778            .iter()
779            .filter_map(|b| {
780                condition(*b, sun, asc, scheme).map(|condition| PlanetCondition {
781                    name: b.name,
782                    condition,
783                })
784            })
785            .collect(),
786        receptions: receptions(&bodies, aspect_between, scheme),
787    })
788}
789
790/// Two points joined by antiscion (mirrored across the solstitial axis, Cancer–Capricorn)
791/// or contra-antiscion (across the equinoctial axis, Aries–Libra).
792#[derive(Debug, Clone, PartialEq, Serialize)]
793pub struct AntisciaContact {
794    /// First point.
795    pub body1: &'static str,
796    /// Second point.
797    pub body2: &'static str,
798    /// "antiscion" or "contra_antiscion".
799    pub kind: &'static str,
800    /// Distance from exactness, degrees (rounded to 0.01).
801    pub orb: f64,
802}
803
804/// The antiscion of a longitude: its mirror across 0° Cancer / 0° Capricorn.
805pub(crate) fn antiscion(longitude: f64) -> f64 {
806    pyfloat::rem(180.0 - longitude, 360.0)
807}
808
809/// The contra-antiscion of a longitude: its mirror across 0° Aries / 0° Libra.
810pub(crate) fn contra_antiscion(longitude: f64) -> f64 {
811    pyfloat::rem(360.0 - longitude, 360.0)
812}
813
814/// Antiscia and contra-antiscia among the seven planets and the angles.
815pub(crate) fn antiscia(planets: &[Placement]) -> Vec<AntisciaContact> {
816    let points: Vec<&Placement> = planets
817        .iter()
818        .filter(|p| SEVEN.contains(&p.name) || matches!(p.name, "Ascendant" | "Midheaven"))
819        .collect();
820    let mut out = Vec::new();
821    for (i, a) in points.iter().enumerate() {
822        for b in &points[i + 1..] {
823            for (kind, mirror) in [
824                ("antiscion", antiscion(a.ecliptic_longitude)),
825                ("contra_antiscion", contra_antiscion(a.ecliptic_longitude)),
826            ] {
827                let orb = pyfloat::separation(mirror, b.ecliptic_longitude);
828                if orb <= ANTISCIA_ORB {
829                    out.push(AntisciaContact {
830                        body1: a.name,
831                        body2: b.name,
832                        kind,
833                        orb: pyfloat::round(orb, 2),
834                    });
835                }
836            }
837        }
838    }
839    out
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    fn lon(sign: &str, degree: f64) -> f64 {
847        sign_index(sign).unwrap() as f64 * 30.0 + degree
848    }
849
850    #[test]
851    fn bound_tables_partition_every_sign() {
852        for table in [&EGYPTIAN_BOUNDS, &LILLY_TERMS] {
853            for bounds in table.iter() {
854                let mut lords: Vec<&str> = bounds.iter().map(|(l, _)| *l).collect();
855                lords.sort_unstable();
856                assert_eq!(lords, ["Jupiter", "Mars", "Mercury", "Saturn", "Venus"]);
857                assert!(bounds.windows(2).all(|w| w[0].1 < w[1].1));
858                assert_eq!(bounds[4].1, 30.0);
859            }
860        }
861    }
862
863    #[test]
864    fn egyptian_bounds_give_each_planet_its_years() {
865        // The minor years of the planets: Saturn 57, Jupiter 79, Mars 66, Venus 82,
866        // Mercury 76 (Valens, Anthology III.13).
867        let mut total = std::collections::HashMap::new();
868        for bounds in EGYPTIAN_BOUNDS {
869            let mut start = 0.0;
870            for (lord, end) in bounds {
871                *total.entry(lord).or_insert(0.0) += end - start;
872                start = end;
873            }
874        }
875        assert_eq!(total["Saturn"], 57.0);
876        assert_eq!(total["Jupiter"], 79.0);
877        assert_eq!(total["Mars"], 66.0);
878        assert_eq!(total["Venus"], 82.0);
879        assert_eq!(total["Mercury"], 76.0);
880    }
881
882    #[test]
883    fn faces_follow_the_chaldean_order_from_mars() {
884        assert_eq!(face_lord(lon("Aries", 0.0)), "Mars");
885        assert_eq!(face_lord(lon("Aries", 15.0)), "Sun");
886        assert_eq!(face_lord(lon("Aries", 29.9)), "Venus");
887        assert_eq!(face_lord(lon("Taurus", 5.0)), "Mercury");
888        assert_eq!(face_lord(lon("Leo", 0.0)), "Saturn");
889        assert_eq!(face_lord(lon("Pisces", 25.0)), "Mars");
890    }
891
892    #[test]
893    fn bounds_by_scheme() {
894        // 10° Aries: Venus in Lilly's terms, Venus in the Egyptian bounds;
895        // 13° Aries: Venus (Lilly) but Mercury (Egyptian).
896        assert_eq!(
897            bound_lord(lon("Aries", 10.0), DignityScheme::Lilly),
898            "Venus"
899        );
900        assert_eq!(
901            bound_lord(lon("Aries", 13.0), DignityScheme::Lilly),
902            "Venus"
903        );
904        assert_eq!(
905            bound_lord(lon("Aries", 13.0), DignityScheme::Dorothean),
906            "Mercury"
907        );
908        assert_eq!(
909            bound_lord(lon("Pisces", 29.99), DignityScheme::Lilly),
910            "Saturn"
911        );
912        assert_eq!(bound_lord(lon("Leo", 0.0), DignityScheme::Lilly), "Saturn");
913        assert_eq!(
914            bound_lord(lon("Leo", 0.0), DignityScheme::Dorothean),
915            "Jupiter"
916        );
917    }
918
919    #[test]
920    fn lillys_points() {
921        // The Sun at 19° Aries by day: exaltation 4, triplicity 3, face (Sun 10-20) 1.
922        let sun = essential_dignity("Sun", lon("Aries", 19.0), true, DignityScheme::Lilly);
923        assert!(sun.exaltation && sun.triplicity && sun.face && !sun.domicile);
924        assert_eq!(sun.score, 8);
925        // Saturn at 5° Aries: fall, and nothing else: −4 −5.
926        let saturn = essential_dignity("Saturn", lon("Aries", 5.0), true, DignityScheme::Lilly);
927        assert!(saturn.fall && saturn.peregrine && !saturn.detriment);
928        assert_eq!(saturn.score, -9);
929        // Mars in water is a triplicity lord by night too, for Lilly only.
930        let mars = essential_dignity("Mars", lon("Pisces", 2.0), true, DignityScheme::Lilly);
931        assert!(mars.triplicity);
932        let mars = essential_dignity("Mars", lon("Pisces", 2.0), true, DignityScheme::Dorothean);
933        assert!(!mars.triplicity);
934        // Venus in Libra: domicile 5, plus her term at 6-11 (Lilly).
935        let venus = essential_dignity("Venus", lon("Libra", 8.0), false, DignityScheme::Lilly);
936        assert!(venus.domicile && venus.bound);
937        assert_eq!(venus.lords.triplicity, ["Saturn", "Mercury"]);
938        // The Moon in Capricorn by night: detriment, but the night lord of earth (Dorothean).
939        let moon = essential_dignity(
940            "Moon",
941            lon("Capricorn", 1.0),
942            false,
943            DignityScheme::Dorothean,
944        );
945        assert!(moon.detriment && moon.triplicity && !moon.peregrine);
946        let moon = essential_dignity(
947            "Moon",
948            lon("Capricorn", 1.0),
949            true,
950            DignityScheme::Dorothean,
951        );
952        assert!(!moon.triplicity);
953        assert_eq!(moon.lords.triplicity, ["Venus", "Moon", "Mars"]);
954    }
955
956    #[test]
957    fn solar_phases() {
958        assert_eq!(solar_phase("Mercury", 100.1, 100.0), Some("cazimi"));
959        assert_eq!(solar_phase("Mercury", 105.0, 100.0), Some("combust"));
960        assert_eq!(solar_phase("Venus", 88.0, 100.0), Some("under_beams"));
961        assert_eq!(solar_phase("Mars", 130.0, 100.0), None);
962        assert_eq!(solar_phase("Sun", 100.0, 100.0), None);
963        assert_eq!(solar_phase("Moon", 355.0, 3.0), Some("combust"));
964    }
965
966    #[test]
967    fn mirrors() {
968        // 10° Gemini mirrors to 20° Cancer (antiscion) and 20° Capricorn (contra).
969        assert!((antiscion(lon("Gemini", 10.0)) - lon("Cancer", 20.0)).abs() < 1e-9);
970        assert!((contra_antiscion(lon("Gemini", 10.0)) - lon("Capricorn", 20.0)).abs() < 1e-9);
971        assert!((antiscion(lon("Capricorn", 5.0)) - lon("Sagittarius", 25.0)).abs() < 1e-9);
972    }
973
974    #[test]
975    fn sect_and_orientality() {
976        // Ascendant at 0° Cancer puts the Midheaven near 0° Aries: a Sun there culminates.
977        assert!(is_diurnal(0.0, 90.0));
978        // Ascendant at 0° Capricorn puts 0° Aries on the lower heaven, below the horizon.
979        assert!(!is_diurnal(0.0, 270.0));
980        assert!(is_oriental(350.0, 0.0));
981        assert!(!is_oriental(10.0, 0.0));
982    }
983}