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