Skip to main content

astroceleste_engine/
degree_qualities.rs

1//! The qualities of the zodiac degrees after Lilly (*Christian Astrology*, 1659, p. 116):
2//! masculine or feminine; light, dark, smoky or void; and whether a degree is deep or
3//! pitted, lame (azimene) or increasing fortune.
4
5use serde::Serialize;
6
7use crate::catalog::{DegreeRun, SignDegrees, DEGREE_QUALITIES};
8use crate::pyfloat;
9
10/// The qualities of the degree an ecliptic longitude falls in.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12pub struct DegreeQualities {
13    /// Zodiac sign name, e.g. "Aries".
14    pub sign: &'static str,
15    /// Ordinal degree (1-30) of the sign: 0°00'-0°59' is the 1st.
16    pub degree: u8,
17    /// "masculine" or "feminine".
18    pub gender: &'static str,
19    /// "light", "dark", "smoky" or "void".
20    pub light: &'static str,
21    /// Deep or pitted degree.
22    pub pitted: bool,
23    /// Lame or deficient (azimene) degree.
24    pub azimene: bool,
25    /// Degree increasing fortune.
26    pub fortune: bool,
27}
28
29/// Lilly's table of the degree qualities, one entry per sign from Aries.
30pub fn degree_quality_table() -> &'static [SignDegrees; 12] {
31    &DEGREE_QUALITIES
32}
33
34/// The qualities of the degree `longitude` (ecliptic, in degrees) falls in. The zodiac is
35/// whatever the longitude is measured in, as for every sign-based rule.
36pub fn degree_qualities(longitude: f64) -> DegreeQualities {
37    let lon = pyfloat::rem(longitude, 360.0);
38    let sign = &DEGREE_QUALITIES[(pyfloat::floordiv(lon, 30.0) as usize) % 12];
39    let degree = (pyfloat::floordiv(pyfloat::rem(lon, 30.0), 1.0) as u8 + 1).clamp(1, 30);
40    DegreeQualities {
41        sign: sign.sign,
42        degree,
43        gender: run_quality(sign.gender, degree),
44        light: run_quality(sign.light, degree),
45        pitted: sign.pitted.contains(&degree),
46        azimene: sign.azimene.contains(&degree),
47        fortune: sign.fortune.contains(&degree),
48    }
49}
50
51fn run_quality(runs: &[DegreeRun], degree: u8) -> &'static str {
52    runs.iter()
53        .find(|run| degree <= run.end)
54        .map_or("", |run| run.quality)
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn every_sign_is_covered() {
63        for sign in degree_quality_table() {
64            for runs in [sign.gender, sign.light] {
65                assert!(
66                    runs.windows(2).all(|w| w[0].end < w[1].end),
67                    "{}",
68                    sign.sign
69                );
70                assert!(runs.windows(2).all(|w| w[0].quality != w[1].quality));
71                assert_eq!(runs.last().unwrap().end, 30, "{}", sign.sign);
72            }
73            assert!(sign
74                .gender
75                .iter()
76                .all(|r| matches!(r.quality, "masculine" | "feminine")));
77            assert!(sign
78                .light
79                .iter()
80                .all(|r| matches!(r.quality, "light" | "dark" | "smoky" | "void")));
81            for list in [sign.pitted, sign.azimene, sign.fortune] {
82                assert!(list.windows(2).all(|w| w[0] < w[1]), "{}", sign.sign);
83                assert!(list.iter().all(|d| (1..=30).contains(d)));
84            }
85        }
86        let signs: Vec<_> = degree_quality_table().iter().map(|s| s.sign).collect();
87        assert_eq!(signs, crate::dignities::SIGNS);
88    }
89
90    #[test]
91    fn degrees_are_ordinal() {
92        // 5°59' Aries is its 6th degree, pitted; 6°00' is the 7th.
93        let sixth = degree_qualities(5.99);
94        assert_eq!((sixth.sign, sixth.degree), ("Aries", 6));
95        assert!(sixth.pitted);
96        let seventh = degree_qualities(6.0);
97        assert_eq!(seventh.degree, 7);
98        assert!(!seventh.pitted);
99        assert_eq!(degree_qualities(0.0).degree, 1);
100        assert_eq!(degree_qualities(29.999).degree, 30);
101    }
102
103    #[test]
104    fn runs_end_on_their_last_degree() {
105        // Aries: masculine to the 8th, feminine the 9th; dark to the 3rd, then light.
106        assert_eq!(degree_qualities(7.5).gender, "masculine");
107        assert_eq!(degree_qualities(8.5).gender, "feminine");
108        assert_eq!(degree_qualities(9.0).gender, "masculine");
109        assert_eq!(degree_qualities(2.9).light, "dark");
110        assert_eq!(degree_qualities(3.0).light, "light");
111    }
112
113    #[test]
114    fn longitudes_wrap() {
115        assert_eq!(degree_qualities(360.0), degree_qualities(0.0));
116        assert_eq!(degree_qualities(-0.5).sign, "Pisces");
117        assert_eq!(degree_qualities(-0.5).degree, 30);
118        assert_eq!(degree_qualities(725.0), degree_qualities(5.0));
119    }
120
121    #[test]
122    fn spot_checks() {
123        // Taurus 6-10 are azimene; 3 increases fortune.
124        let taurus_8th = degree_qualities(37.5);
125        assert!(taurus_8th.azimene && !taurus_8th.pitted);
126        assert_eq!(taurus_8th.light, "void");
127        assert!(degree_qualities(32.0).fortune);
128        // Leo 28: pitted and azimene, light.
129        let leo_28th = degree_qualities(147.2);
130        assert_eq!(leo_28th.sign, "Leo");
131        assert!(leo_28th.pitted && leo_28th.azimene);
132        assert_eq!(leo_28th.light, "light");
133        // Capricorn 11-15 smoky, 12-14 increase fortune.
134        let cap_13th = degree_qualities(282.5);
135        assert_eq!((cap_13th.light, cap_13th.fortune), ("smoky", true));
136        assert_eq!(cap_13th.gender, "feminine");
137        // Aquarius ends feminine and light.
138        let last = degree_qualities(329.9);
139        assert_eq!((last.gender, last.light), ("feminine", "light"));
140    }
141}