astroceleste_engine/vedic/
dasha.rs1use serde::Serialize;
6
7use super::{nakshatra, DASHA_LORDS};
8use crate::error::EngineError;
9use crate::instant::UtcInstant;
10
11const CYCLE_YEARS: f64 = 120.0;
13pub const MAX_DASHA_LEVELS: usize = 5;
15pub const MAX_DASHA_DEPTH: usize = 3;
17
18const YEARS: [(&str, f64); 4] = [
20 ("julian", 365.25),
21 ("gregorian", 365.2425),
22 ("sidereal", 365.256_363),
23 ("savana", 360.0),
24];
25
26#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct DashaPeriod {
29 pub lord: &'static str,
31 pub start: String,
33 pub end: String,
35 #[serde(skip_serializing_if = "Vec::is_empty")]
37 pub periods: Vec<DashaPeriod>,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize)]
42pub struct Vimshottari {
43 pub year: &'static str,
45 pub birth_lord: &'static str,
47 pub balance_years: f64,
49 pub path: Vec<&'static str>,
51 pub periods: Vec<DashaPeriod>,
53 pub current: Vec<DashaPeriod>,
56}
57
58#[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
79pub 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 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 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 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}