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