Skip to main content

astroceleste_engine/
time_lords.rs

1//! Time-lords of a nativity: annual profections and the firdaria.
2//!
3//! Not part of the reference implementation. Neither needs the ephemeris: they unfold from
4//! the moment of birth, the natal Ascendant and the sect of the chart.
5
6use serde::Serialize;
7
8use crate::dignities::{is_diurnal, traditional_ruler, SEVEN, SIGNS};
9use crate::instant::UtcInstant;
10use crate::pyfloat;
11
12/// A tropical year, in days: the years of profections and firdaria are counted from birth.
13const YEAR_DAYS: f64 = 365.242_19;
14
15/// Firdaria of a day birth: lord and years (Bonatti, the nodes last).
16const DIURNAL_FIRDARIA: [(&str, f64); 9] = [
17    ("Sun", 10.0),
18    ("Venus", 8.0),
19    ("Mercury", 13.0),
20    ("Moon", 9.0),
21    ("Saturn", 11.0),
22    ("Jupiter", 12.0),
23    ("Mars", 7.0),
24    ("North Node", 3.0),
25    ("South Node", 2.0),
26];
27
28/// Firdaria of a night birth.
29const NOCTURNAL_FIRDARIA: [(&str, f64); 9] = [
30    ("Moon", 9.0),
31    ("Saturn", 11.0),
32    ("Jupiter", 12.0),
33    ("Mars", 7.0),
34    ("Sun", 10.0),
35    ("Venus", 8.0),
36    ("Mercury", 13.0),
37    ("North Node", 3.0),
38    ("South Node", 2.0),
39];
40
41/// The years of a full round of firdaria.
42const FIRDARIA_CYCLE: f64 = 75.0;
43
44/// One year of life and the sign the Ascendant has profected to.
45#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct Profection {
47    /// Completed years of age at the start of the year.
48    pub age: u32,
49    /// Start of the year (a birthday), ISO 8601 UTC.
50    pub start: String,
51    /// End of the year (the next birthday), ISO 8601 UTC.
52    pub end: String,
53    /// The profected sign: one sign for each year from the natal Ascendant.
54    pub sign: &'static str,
55    /// The lord of the year: the ruler of the profected sign.
56    pub lord: &'static str,
57    /// The activated house (whole signs from the Ascendant), 1-12.
58    pub house: u8,
59}
60
61/// A span of time ruled by a planet.
62#[derive(Debug, Clone, PartialEq, Serialize)]
63pub struct Period {
64    /// The ruling planet (or lunar node).
65    pub lord: &'static str,
66    /// Start, ISO 8601 UTC.
67    pub start: String,
68    /// End, ISO 8601 UTC.
69    pub end: String,
70}
71
72/// A major period of the firdaria, with its seven sub-periods (none for the nodes).
73#[derive(Debug, Clone, PartialEq, Serialize)]
74pub struct Firdaria {
75    /// The major lord.
76    pub lord: &'static str,
77    /// Start, ISO 8601 UTC.
78    pub start: String,
79    /// End, ISO 8601 UTC.
80    pub end: String,
81    /// Sub-periods: the major lord first, then the planets in Chaldean order.
82    pub sub_periods: Vec<Period>,
83}
84
85/// The time-lords active over a span of a life.
86#[derive(Debug, Clone, PartialEq, Serialize)]
87pub struct TimeLords {
88    /// Whether the birth is diurnal (the Sun above the horizon), which orders the firdaria.
89    pub diurnal: bool,
90    /// The profection years that overlap the span.
91    pub profections: Vec<Profection>,
92    /// The firdaria periods that overlap the span.
93    pub firdaria: Vec<Firdaria>,
94}
95
96fn at(birth: UtcInstant, years: f64) -> UtcInstant {
97    birth.plus_days(years * YEAR_DAYS)
98}
99
100/// Annual profections and firdaria from `birth` (with the natal Sun's and Ascendant's
101/// longitudes), for the years that overlap `start`..`end`.
102pub fn time_lords(
103    birth: UtcInstant,
104    sun_longitude: f64,
105    ascendant_longitude: f64,
106    start: UtcInstant,
107    end: UtcInstant,
108) -> TimeLords {
109    let (start, end) = if end < start {
110        (end, start)
111    } else {
112        (start, end)
113    };
114    let start = start.max(birth);
115    let end = end.max(birth);
116    let diurnal = is_diurnal(sun_longitude, ascendant_longitude);
117    let age_at = |t: UtcInstant| t.seconds_since(&birth) / 86_400.0 / YEAR_DAYS;
118
119    let asc_sign =
120        (pyfloat::floordiv(pyfloat::rem(ascendant_longitude, 360.0), 30.0) as usize).min(11);
121    let first = age_at(start).floor() as u32;
122    let last = age_at(end).floor() as u32;
123    let profections = (first..=last)
124        .map(|age| {
125            let sign = SIGNS[(asc_sign + age as usize) % 12];
126            Profection {
127                age,
128                start: at(birth, age as f64).isoformat(),
129                end: at(birth, age as f64 + 1.0).isoformat(),
130                sign,
131                lord: traditional_ruler(sign).unwrap_or("Mars"),
132                house: (age % 12) as u8 + 1,
133            }
134        })
135        .collect();
136
137    let sequence = if diurnal {
138        DIURNAL_FIRDARIA
139    } else {
140        NOCTURNAL_FIRDARIA
141    };
142    let (from, to) = (age_at(start), age_at(end));
143    let mut firdaria = Vec::new();
144    let mut cycle_start = (from / FIRDARIA_CYCLE).floor() * FIRDARIA_CYCLE;
145    'cycles: loop {
146        let mut offset = cycle_start;
147        for (lord, years) in sequence {
148            let (period_start, period_end) = (offset, offset + years);
149            offset = period_end;
150            if period_start > to {
151                break 'cycles;
152            }
153            if period_end <= from {
154                continue;
155            }
156            let sub_periods = if SEVEN.contains(&lord) {
157                let first = SEVEN.iter().position(|p| *p == lord).unwrap_or(0);
158                (0..7)
159                    .map(|i| {
160                        let length = years / 7.0;
161                        Period {
162                            lord: SEVEN[(first + i) % 7],
163                            start: at(birth, period_start + length * i as f64).isoformat(),
164                            end: at(birth, period_start + length * (i + 1) as f64).isoformat(),
165                        }
166                    })
167                    .collect()
168            } else {
169                Vec::new()
170            };
171            firdaria.push(Firdaria {
172                lord,
173                start: at(birth, period_start).isoformat(),
174                end: at(birth, period_end).isoformat(),
175                sub_periods,
176            });
177        }
178        cycle_start += FIRDARIA_CYCLE;
179    }
180
181    TimeLords {
182        diurnal,
183        profections,
184        firdaria,
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn utc(text: &str) -> UtcInstant {
193        UtcInstant::parse(text).unwrap()
194    }
195
196    #[test]
197    fn firdaria_sequences_last_seventy_five_years() {
198        for sequence in [DIURNAL_FIRDARIA, NOCTURNAL_FIRDARIA] {
199            assert_eq!(sequence.iter().map(|(_, y)| y).sum::<f64>(), FIRDARIA_CYCLE);
200        }
201    }
202
203    #[test]
204    fn profections_move_one_sign_a_year() {
205        // Ascendant at 10° Leo: age 0 Leo (Sun), age 1 Virgo (Mercury), age 12 Leo again.
206        let birth = utc("1990-01-01T12:00:00Z");
207        let lords = time_lords(birth, 280.0, 130.0, birth, utc("2003-01-01T00:00:00Z"));
208        let p = &lords.profections;
209        assert_eq!(p[0].sign, "Leo");
210        assert_eq!(p[0].lord, "Sun");
211        assert_eq!(p[0].house, 1);
212        assert_eq!(p[1].sign, "Virgo");
213        assert_eq!(p[1].lord, "Mercury");
214        assert_eq!(p[12].sign, "Leo");
215        assert_eq!(p[12].house, 1);
216        assert_eq!(p[3].house, 4);
217    }
218
219    #[test]
220    fn a_day_birth_starts_with_the_sun() {
221        // Sun at 0° Aries and Ascendant at 0° Cancer: the Sun culminates, a day birth.
222        let birth = utc("2000-03-20T12:00:00Z");
223        let lords = time_lords(birth, 0.0, 90.0, birth, birth.plus_days(1.0));
224        assert!(lords.diurnal);
225        let first = &lords.firdaria[0];
226        assert_eq!(first.lord, "Sun");
227        let subs: Vec<&str> = first.sub_periods.iter().map(|s| s.lord).collect();
228        assert_eq!(
229            subs,
230            ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
231        );
232        assert_eq!(first.end, at(birth, 10.0).isoformat());
233    }
234
235    #[test]
236    fn a_night_birth_starts_with_the_moon_and_the_nodes_have_no_sub_periods() {
237        let birth = utc("2000-03-20T00:00:00Z");
238        // Ascendant at 0° Capricorn puts the Sun at 0° Aries below the horizon.
239        let lords = time_lords(birth, 0.0, 270.0, birth, at(birth, 74.0));
240        assert!(!lords.diurnal);
241        let names: Vec<&str> = lords.firdaria.iter().map(|f| f.lord).collect();
242        assert_eq!(
243            names,
244            [
245                "Moon",
246                "Saturn",
247                "Jupiter",
248                "Mars",
249                "Sun",
250                "Venus",
251                "Mercury",
252                "North Node",
253                "South Node"
254            ]
255        );
256        assert!(lords.firdaria[7].sub_periods.is_empty());
257        assert_eq!(lords.firdaria[1].sub_periods[0].lord, "Saturn");
258        assert_eq!(lords.firdaria[1].sub_periods[1].lord, "Jupiter");
259    }
260
261    #[test]
262    fn a_span_late_in_life_wraps_into_a_new_round() {
263        let birth = utc("1900-01-01T12:00:00Z");
264        let lords = time_lords(birth, 280.0, 130.0, at(birth, 76.0), at(birth, 77.0));
265        // Night birth (the Sun below a Leo Ascendant): the round starts again with the Moon.
266        assert_eq!(lords.firdaria.len(), 1);
267        assert_eq!(lords.firdaria[0].lord, "Moon");
268        assert_eq!(lords.profections[0].age, 76);
269    }
270}