1pub(crate) mod calendar;
11pub(crate) mod dasha;
12pub(crate) mod varga;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::error::EngineError;
18use crate::instant::UtcInstant;
19use crate::zodiac::{ayanamsa_info, AYANAMSA_CATALOG, ZODIAC_SIGNS};
20use dasha::{vimshottari, Vimshottari};
21use varga::{varga_position, Hora, VARGAS};
22
23pub const NAKSHATRAS: [&str; 27] = [
25 "ashwini",
26 "bharani",
27 "krittika",
28 "rohini",
29 "mrigashira",
30 "ardra",
31 "punarvasu",
32 "pushya",
33 "ashlesha",
34 "magha",
35 "purva_phalguni",
36 "uttara_phalguni",
37 "hasta",
38 "chitra",
39 "swati",
40 "vishakha",
41 "anuradha",
42 "jyeshtha",
43 "mula",
44 "purva_ashadha",
45 "uttara_ashadha",
46 "shravana",
47 "dhanishta",
48 "shatabhisha",
49 "purva_bhadrapada",
50 "uttara_bhadrapada",
51 "revati",
52];
53
54pub(crate) const NAKSHATRA_SPAN: f64 = 360.0 / 27.0;
56
57pub(crate) const DASHA_LORDS: [(&str, f64); 9] = [
60 ("South Node", 7.0),
61 ("Venus", 20.0),
62 ("Sun", 6.0),
63 ("Moon", 10.0),
64 ("Mars", 7.0),
65 ("North Node", 18.0),
66 ("Jupiter", 16.0),
67 ("Saturn", 19.0),
68 ("Mercury", 17.0),
69];
70
71const GRAHAS: [&str; 9] = [
73 "Sun",
74 "Moon",
75 "Mars",
76 "Mercury",
77 "Jupiter",
78 "Venus",
79 "Saturn",
80 "North Node",
81 "South Node",
82];
83const OUTER_PLANETS: [&str; 3] = ["Uranus", "Neptune", "Pluto"];
84
85#[derive(Debug, Clone, PartialEq, Serialize)]
87pub struct Nakshatra {
88 pub number: u8,
90 pub code: &'static str,
92 pub lord: &'static str,
94 pub pada: u8,
96 pub elapsed: f64,
98}
99
100pub fn nakshatra(longitude: f64) -> Nakshatra {
102 let longitude = longitude.rem_euclid(360.0);
103 let index = ((longitude / NAKSHATRA_SPAN) as usize).min(26);
104 let elapsed = ((longitude - index as f64 * NAKSHATRA_SPAN) / NAKSHATRA_SPAN).clamp(0.0, 1.0);
105 Nakshatra {
106 number: index as u8 + 1,
107 code: NAKSHATRAS[index],
108 lord: DASHA_LORDS[index % 9].0,
109 pada: ((elapsed * 4.0) as u8).min(3) + 1,
110 elapsed,
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Deserialize)]
116#[serde(default)]
117pub struct JyotishOptions {
118 pub ayanamsa: String,
120 pub outer_planets: bool,
122 pub vargas: Option<Vec<u8>>,
124 pub hora: String,
126 pub dasha_year: String,
128 pub dasha_path: Vec<String>,
130 pub dasha_depth: usize,
132 pub date: Option<String>,
134}
135
136impl Default for JyotishOptions {
137 fn default() -> Self {
138 JyotishOptions {
139 ayanamsa: "lahiri".into(),
140 outer_planets: false,
141 vargas: None,
142 hora: "parashara".into(),
143 dasha_year: "julian".into(),
144 dasha_path: Vec::new(),
145 dasha_depth: 2,
146 date: None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize)]
153pub struct Graha {
154 pub name: &'static str,
156 pub longitude: f64,
158 pub sign: &'static str,
160 pub degree: f64,
162 pub house: u8,
164 pub is_retrograde: bool,
166 pub nakshatra: Nakshatra,
168 pub vargottama: bool,
170}
171
172#[derive(Debug, Clone, PartialEq, Serialize)]
174pub struct VargaPlacement {
175 pub name: &'static str,
177 pub sign: &'static str,
179 pub degree: f64,
181 pub house: u8,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize)]
187pub struct Varga {
188 pub code: String,
190 pub division: u8,
192 pub placements: Vec<VargaPlacement>,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize)]
198pub struct Jyotish {
199 pub ayanamsa: &'static str,
201 pub ayanamsa_value: f64,
203 pub grahas: Vec<Graha>,
205 pub vargas: Vec<Varga>,
207 pub vimshottari: Vimshottari,
209}
210
211fn invalid(what: &str) -> EngineError {
212 EngineError::InvalidInput(format!("jyotish: {what}"))
213}
214
215fn whole_sign_house(sign: usize, ascendant_sign: usize) -> u8 {
216 ((sign + 12 - ascendant_sign) % 12) as u8 + 1
217}
218
219pub fn jyotish(
223 chart: &Value,
224 birth: UtcInstant,
225 options: &JyotishOptions,
226) -> Result<Jyotish, EngineError> {
227 let code = options.ayanamsa.trim().to_lowercase();
228 if !AYANAMSA_CATALOG.iter().any(|a| a.code == code) {
229 return Err(invalid(&format!("unknown ayanamsa {}", options.ayanamsa)));
230 }
231 let jd = birth.julian_day();
232 let info = ayanamsa_info(jd, &code);
233 let chart_shift = if chart.get("zodiac_type").and_then(Value::as_str) == Some("sidereal") {
235 chart
236 .get("ayanamsa_value")
237 .and_then(Value::as_f64)
238 .ok_or_else(|| invalid("a sidereal chart needs its ayanamsa_value"))?
239 } else {
240 0.0
241 };
242 let planets = chart
243 .get("planets")
244 .and_then(Value::as_array)
245 .ok_or_else(|| invalid("the chart has no planets"))?;
246 let find = |name: &str| -> Option<(f64, bool)> {
247 let p = planets
248 .iter()
249 .find(|p| p.get("name").and_then(Value::as_str) == Some(name))?;
250 let longitude = p.get("ecliptic_longitude")?.as_f64()?;
251 longitude.is_finite().then(|| {
252 (
253 (longitude + chart_shift - info.value).rem_euclid(360.0),
254 p.get("is_retrograde")
255 .and_then(Value::as_bool)
256 .unwrap_or(false),
257 )
258 })
259 };
260
261 let hora = Hora::from_code(&options.hora);
262 let names = std::iter::once("Ascendant")
263 .chain(GRAHAS)
264 .chain(OUTER_PLANETS.into_iter().filter(|_| options.outer_planets));
265 let points: Vec<(&'static str, f64, bool)> = names
266 .filter_map(|name| find(name).map(|(lon, retro)| (name, lon, retro)))
267 .collect();
268 let (ascendant, moon) = (
269 points.iter().find(|p| p.0 == "Ascendant"),
270 points.iter().find(|p| p.0 == "Moon"),
271 );
272 let (Some(&(_, ascendant, _)), Some(&(_, moon, _))) = (ascendant, moon) else {
273 return Err(invalid("the chart needs the Ascendant and the Moon"));
274 };
275
276 let sign_of = |lon: f64| ((lon / 30.0) as usize).min(11);
277 let ascendant_sign = sign_of(ascendant);
278 let grahas = points
279 .iter()
280 .map(|&(name, longitude, is_retrograde)| {
281 let sign = sign_of(longitude);
282 Ok(Graha {
283 name,
284 longitude,
285 sign: ZODIAC_SIGNS[sign].name,
286 degree: longitude - sign as f64 * 30.0,
287 house: whole_sign_house(sign, ascendant_sign),
288 is_retrograde,
289 nakshatra: nakshatra(longitude),
290 vargottama: varga_position(longitude, 9, hora)?.0 == sign,
291 })
292 })
293 .collect::<Result<Vec<_>, EngineError>>()?;
294
295 let divisions = options.vargas.clone().unwrap_or_else(|| VARGAS.to_vec());
296 let vargas = divisions
297 .iter()
298 .map(|&n| {
299 let ascendant_sign = varga_position(ascendant, n, hora)?.0;
300 let placements = points
301 .iter()
302 .map(|&(name, longitude, _)| {
303 let (sign, degree) = varga_position(longitude, n, hora)?;
304 Ok(VargaPlacement {
305 name,
306 sign: ZODIAC_SIGNS[sign].name,
307 degree,
308 house: whole_sign_house(sign, ascendant_sign),
309 })
310 })
311 .collect::<Result<Vec<_>, EngineError>>()?;
312 Ok(Varga {
313 code: format!("D{n}"),
314 division: n,
315 placements,
316 })
317 })
318 .collect::<Result<Vec<_>, EngineError>>()?;
319
320 let date = options
321 .date
322 .as_deref()
323 .map(UtcInstant::parse)
324 .transpose()
325 .map_err(|e| invalid(&e.to_string()))?;
326 let vimshottari = vimshottari(
327 birth,
328 moon,
329 &options.dasha_year,
330 &options.dasha_path,
331 options.dasha_depth,
332 date,
333 )?;
334
335 Ok(Jyotish {
336 ayanamsa: info.code,
337 ayanamsa_value: info.value,
338 grahas,
339 vargas,
340 vimshottari,
341 })
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use serde_json::json;
348
349 #[test]
350 fn nakshatra_boundaries() {
351 let n = nakshatra(0.0);
352 assert_eq!(
353 (n.number, n.code, n.lord, n.pada),
354 (1, "ashwini", "South Node", 1)
355 );
356 assert_eq!(nakshatra(3.34).pada, 2);
357 let n = nakshatra(26.7);
358 assert_eq!((n.code, n.lord), ("krittika", "Sun"));
359 let n = nakshatra(359.9);
360 assert_eq!(
361 (n.number, n.code, n.lord, n.pada),
362 (27, "revati", "Mercury", 4)
363 );
364 assert_eq!(nakshatra(-1e-17).number, 27); }
366
367 fn chart(zodiac: &str, shift: f64) -> Value {
368 let at = |lon: f64| (lon - shift).rem_euclid(360.0);
369 json!({
370 "zodiac_type": zodiac,
371 "ayanamsa_value": shift,
372 "planets": [
373 {"name": "Sun", "ecliptic_longitude": at(100.0), "is_retrograde": false},
374 {"name": "Moon", "ecliptic_longitude": at(200.0), "is_retrograde": false},
375 {"name": "Mars", "ecliptic_longitude": at(10.0), "is_retrograde": true},
376 {"name": "Ascendant", "ecliptic_longitude": at(50.0)},
377 ]
378 })
379 }
380
381 #[test]
382 fn tropical_and_sidereal_charts_agree() {
383 let birth = UtcInstant::parse("2000-01-01T12:00:00Z").unwrap();
384 let options = JyotishOptions::default();
385 let tropical = jyotish(&chart("tropical", 0.0), birth, &options).unwrap();
386 let sidereal = jyotish(&chart("sidereal", 24.1), birth, &options).unwrap();
387 assert_eq!(tropical.ayanamsa, "lahiri");
388 for (a, b) in tropical.grahas.iter().zip(&sidereal.grahas) {
389 assert_eq!(a.name, b.name);
390 assert!((a.longitude - b.longitude).abs() < 1e-9);
391 }
392 let names: Vec<&str> = tropical.grahas.iter().map(|g| g.name).collect();
393 assert_eq!(names, ["Ascendant", "Sun", "Moon", "Mars"]);
394 assert_eq!(tropical.grahas[0].house, 1);
395 assert!(tropical.grahas[3].is_retrograde);
396 assert_eq!(tropical.vargas.len(), VARGAS.len());
397 assert_eq!(tropical.vargas[8].code, "D9");
398 }
399
400 #[test]
401 fn options_are_checked() {
402 let birth = UtcInstant::parse("2000-01-01T12:00:00Z").unwrap();
403 let bad_ayanamsa = JyotishOptions {
404 ayanamsa: "nope".into(),
405 ..JyotishOptions::default()
406 };
407 assert!(jyotish(&chart("tropical", 0.0), birth, &bad_ayanamsa).is_err());
408 let no_moon = json!({"planets": [{"name": "Ascendant", "ecliptic_longitude": 1.0}]});
409 assert!(jyotish(&no_moon, birth, &JyotishOptions::default()).is_err());
410 }
411}