Skip to main content

astroceleste_engine/vedic/
dasha.rs

1//! The Vimshottari dasha: 120 years of planetary periods, starting with the lord of the
2//! Moon's nakshatra at birth, each period divided among the nine lords in the same order
3//! and in proportion to their years.
4
5use serde::Serialize;
6
7use super::{nakshatra, DASHA_LORDS};
8use crate::error::EngineError;
9use crate::instant::UtcInstant;
10
11/// The whole cycle, years.
12const CYCLE_YEARS: f64 = 120.0;
13/// Levels below the mahadashas: antardasha, pratyantardasha, sookshma, prana.
14pub const MAX_DASHA_LEVELS: usize = 5;
15/// Most levels returned in one tree below `path`.
16pub const MAX_DASHA_DEPTH: usize = 3;
17
18/// The length of a dasha year: (code, days).
19const YEARS: [(&str, f64); 4] = [
20    ("julian", 365.25),
21    ("gregorian", 365.2425),
22    ("sidereal", 365.256_363),
23    ("savana", 360.0),
24];
25
26/// A dasha period and, to the depth asked, its subperiods.
27#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct DashaPeriod {
29    /// The ruling body (engine names: "North Node" is Rahu, "South Node" Ketu).
30    pub lord: &'static str,
31    /// ISO 8601 UTC, to the second (the first mahadasha starts before the birth).
32    pub start: String,
33    /// When it ends, ISO 8601 UTC to the second.
34    pub end: String,
35    /// Its subperiods, to the depth asked (left out when empty).
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub periods: Vec<DashaPeriod>,
38}
39
40/// The Vimshottari dasha of a birth, in a [`Jyotish`](crate::Jyotish) reading.
41#[derive(Debug, Clone, PartialEq, Serialize)]
42pub struct Vimshottari {
43    /// Dasha year: "julian" (365.25 days), "gregorian", "sidereal" or "savana" (360).
44    pub year: &'static str,
45    /// The lord of the Moon's nakshatra, whose mahadasha runs at birth.
46    pub birth_lord: &'static str,
47    /// Years of that mahadasha left at birth.
48    pub balance_years: f64,
49    /// The lords leading to `periods` (empty: `periods` are the mahadashas).
50    pub path: Vec<&'static str>,
51    /// The periods under `path`, `depth` levels deep.
52    pub periods: Vec<DashaPeriod>,
53    /// The periods running at the date asked, mahadasha first, down to the prana dasha;
54    /// empty without a date or outside the 120 years.
55    pub current: Vec<DashaPeriod>,
56}
57
58/// One period by its offsets from the birth, in days.
59#[derive(Clone, Copy)]
60struct Span {
61    lord: usize,
62    start: f64,
63    days: f64,
64}
65
66impl Span {
67    fn children(self) -> impl Iterator<Item = Span> {
68        let mut start = self.start;
69        (0..9).map(move |k| {
70            let lord = (self.lord + k) % 9;
71            let days = self.days * DASHA_LORDS[lord].1 / CYCLE_YEARS;
72            let child = Span { lord, start, days };
73            start += days;
74            child
75        })
76    }
77}
78
79/// The Vimshottari dasha from `birth` and the Moon's sidereal longitude: the periods under
80/// `path` (lords from the mahadasha down) to `depth` levels, and those running at `date`.
81pub fn vimshottari(
82    birth: UtcInstant,
83    moon_longitude: f64,
84    year: &str,
85    path: &[String],
86    depth: usize,
87    date: Option<UtcInstant>,
88) -> Result<Vimshottari, EngineError> {
89    let (year, year_days) = YEARS
90        .iter()
91        .copied()
92        .find(|(code, _)| code.eq_ignore_ascii_case(year.trim()))
93        .ok_or_else(|| EngineError::InvalidInput(format!("unknown dasha year: {year}")))?;
94    if !(1..=MAX_DASHA_DEPTH).contains(&depth) || path.len() + depth > MAX_DASHA_LEVELS {
95        return Err(EngineError::InvalidInput(format!(
96            "dasha depth must be 1-{MAX_DASHA_DEPTH}, with at most {MAX_DASHA_LEVELS} levels in all"
97        )));
98    }
99    if !moon_longitude.is_finite() {
100        return Err(EngineError::InvalidInput(format!(
101            "moon longitude must be finite, got {moon_longitude}"
102        )));
103    }
104
105    let moon = nakshatra(moon_longitude);
106    let first = (usize::from(moon.number) - 1) % 9;
107    let first_days = DASHA_LORDS[first].1 * year_days;
108    let cycle = Span {
109        lord: first,
110        start: -moon.elapsed * first_days,
111        days: CYCLE_YEARS * year_days,
112    };
113
114    let mut parent = cycle;
115    let mut lords = Vec::with_capacity(path.len());
116    for name in path {
117        parent = parent
118            .children()
119            .find(|c| DASHA_LORDS[c.lord].0.eq_ignore_ascii_case(name.trim()))
120            .ok_or_else(|| EngineError::InvalidInput(format!("not a dasha lord: {name}")))?;
121        lords.push(DASHA_LORDS[parent.lord].0);
122    }
123
124    let at = |days: f64| -> String {
125        let micros = (days * 86_400.0).round() as i64 * 1_000_000;
126        birth.add_micros(micros).isoformat()
127    };
128    fn tree(span: Span, depth: usize, at: &dyn Fn(f64) -> String) -> Vec<DashaPeriod> {
129        if depth == 0 {
130            return Vec::new();
131        }
132        span.children()
133            .map(|c| DashaPeriod {
134                lord: DASHA_LORDS[c.lord].0,
135                start: at(c.start),
136                end: at(c.start + c.days),
137                periods: tree(c, depth - 1, at),
138            })
139            .collect()
140    }
141
142    let mut current = Vec::new();
143    if let Some(date) = date {
144        let offset = date.seconds_since(&birth) / 86_400.0;
145        let mut span = cycle;
146        if offset >= span.start && offset < span.start + span.days {
147            for _ in 0..MAX_DASHA_LEVELS {
148                let Some(c) = span
149                    .children()
150                    .find(|c| offset < c.start + c.days)
151                    .or_else(|| span.children().last())
152                else {
153                    break;
154                };
155                current.push(DashaPeriod {
156                    lord: DASHA_LORDS[c.lord].0,
157                    start: at(c.start),
158                    end: at(c.start + c.days),
159                    periods: Vec::new(),
160                });
161                span = c;
162            }
163        }
164    }
165
166    Ok(Vimshottari {
167        year,
168        birth_lord: DASHA_LORDS[first].0,
169        balance_years: (1.0 - moon.elapsed) * DASHA_LORDS[first].1,
170        path: lords,
171        periods: tree(parent, depth, &at),
172        current,
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn birth() -> UtcInstant {
181        UtcInstant::parse("2000-01-01T00:00:00Z").unwrap()
182    }
183
184    #[test]
185    fn mahadashas_start_with_the_moons_lord_and_fill_120_years() {
186        // 10° sidereal: Ashwini (Ketu), three quarters elapsed.
187        let d = vimshottari(birth(), 10.0, "julian", &[], 1, None).unwrap();
188        assert_eq!(d.birth_lord, "South Node");
189        assert!((d.balance_years - 7.0 * 0.25).abs() < 1e-12);
190        let lords: Vec<&str> = d.periods.iter().map(|p| p.lord).collect();
191        assert_eq!(
192            lords,
193            [
194                "South Node",
195                "Venus",
196                "Sun",
197                "Moon",
198                "Mars",
199                "North Node",
200                "Jupiter",
201                "Saturn",
202                "Mercury"
203            ]
204        );
205        // Ketu's balance: 1.75 years of 365.25 days after the birth.
206        assert_eq!(d.periods[0].end, "2001-10-01T04:30:00+00:00");
207        assert_eq!(d.periods[1].start, d.periods[0].end);
208        let start = UtcInstant::parse(&d.periods[0].start).unwrap();
209        let end = UtcInstant::parse(&d.periods[8].end).unwrap();
210        assert!((end.seconds_since(&start) / 86_400.0 - 120.0 * 365.25).abs() < 1e-3);
211    }
212
213    #[test]
214    fn subperiods_follow_their_parent() {
215        let path = ["Venus".to_string()];
216        let d = vimshottari(birth(), 10.0, "savana", &path, 2, None).unwrap();
217        assert_eq!(d.path, ["Venus"]);
218        assert_eq!(d.periods[0].lord, "Venus");
219        assert_eq!(d.periods[1].lord, "Sun");
220        assert_eq!(d.periods[0].periods.len(), 9);
221        // Venus-Venus: 20 × 20 / 120 years of 360 days.
222        let start = UtcInstant::parse(&d.periods[0].start).unwrap();
223        let end = UtcInstant::parse(&d.periods[0].end).unwrap();
224        assert!((end.seconds_since(&start) / 86_400.0 - 20.0 * 20.0 / 120.0 * 360.0).abs() < 1e-3);
225    }
226
227    #[test]
228    fn current_periods_contain_the_date() {
229        let date = UtcInstant::parse("2030-06-15T00:00:00Z").unwrap();
230        let d = vimshottari(birth(), 200.0, "julian", &[], 1, Some(date)).unwrap();
231        assert_eq!(d.current.len(), MAX_DASHA_LEVELS);
232        for p in &d.current {
233            assert!(UtcInstant::parse(&p.start).unwrap() <= date, "{p:?}");
234            assert!(date < UtcInstant::parse(&p.end).unwrap(), "{p:?}");
235        }
236        let late = UtcInstant::parse("2200-01-01T00:00:00Z").unwrap();
237        assert!(vimshottari(birth(), 200.0, "julian", &[], 1, Some(late))
238            .unwrap()
239            .current
240            .is_empty());
241    }
242
243    #[test]
244    fn bad_options_are_rejected() {
245        assert!(vimshottari(birth(), 10.0, "lunar", &[], 1, None).is_err());
246        assert!(vimshottari(birth(), 10.0, "julian", &[], 4, None).is_err());
247        assert!(vimshottari(birth(), 10.0, "julian", &["Pluto".into()], 1, None).is_err());
248        let deep: Vec<String> = ["Venus", "Venus", "Venus"].map(String::from).to_vec();
249        assert!(vimshottari(birth(), 10.0, "julian", &deep, 3, None).is_err());
250    }
251}