Skip to main content

astroceleste_engine/vedic/
mod.rs

1//! Vedic astrology (Jyotish): the sidereal placements, nakshatras, divisional charts
2//! (vargas) and Vimshottari dasha of a chart ([`jyotish`], no kernel needed), and the
3//! panchanga of a moment ([`calendar`], from the kernels).
4//!
5//! Not part of the reference implementation. Results carry stable codes only (lowercase
6//! ASCII transliterations for nakshatras, tithis, yogas, karanas, months and years, the
7//! engine's names for bodies and signs: "North Node" is Rahu, "South Node" Ketu); wording
8//! belongs to the application. Parts are ported from Priyank Gahtori's Khagole fork.
9
10pub(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
23/// The 27 nakshatras from Ashwini, 13°20′ each from 0° sidereal Aries.
24pub 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
54/// Span of a nakshatra, degrees.
55pub(crate) const NAKSHATRA_SPAN: f64 = 360.0 / 27.0;
56
57/// The Vimshottari lords in order with their years: Ashwini's lord (Ketu) first, then
58/// repeating every nine nakshatras.
59pub(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
71/// The nine grahas, in the order results list them.
72const 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/// The nakshatra of a sidereal longitude.
86#[derive(Debug, Clone, PartialEq, Serialize)]
87pub struct Nakshatra {
88    /// 1 (Ashwini) to 27 (Revati).
89    pub number: u8,
90    /// Stable code, e.g. "ashwini".
91    pub code: &'static str,
92    /// Its Vimshottari lord.
93    pub lord: &'static str,
94    /// Quarter, 1 to 4.
95    pub pada: u8,
96    /// Fraction of the nakshatra already crossed, 0 to 1.
97    pub elapsed: f64,
98}
99
100/// The nakshatra of sidereal longitude `longitude` (degrees).
101pub 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/// What [`jyotish`] computes; every field is optional in JSON.
115#[derive(Debug, Clone, PartialEq, Deserialize)]
116#[serde(default)]
117pub struct JyotishOptions {
118    /// Ayanamsa code from the catalog (default "lahiri"); unknown codes are an error.
119    pub ayanamsa: String,
120    /// Also list Uranus, Neptune and Pluto (default `false`).
121    pub outer_planets: bool,
122    /// The vargas, by division (default all of [`VARGAS`]).
123    pub vargas: Option<Vec<u8>>,
124    /// The hora (D2): "parashara" (default) or "cyclic".
125    pub hora: String,
126    /// The dasha year: "julian" (365.25 days, default), "gregorian", "sidereal" or "savana".
127    pub dasha_year: String,
128    /// The lords from a mahadasha down whose subperiods to list (default: the mahadashas).
129    pub dasha_path: Vec<String>,
130    /// Levels of the dasha tree below `dasha_path`, 1 to 3 (default 2).
131    pub dasha_depth: usize,
132    /// List the dasha periods running at this moment (ISO 8601 UTC).
133    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/// A body or the Ascendant in the sidereal zodiac.
152#[derive(Debug, Clone, PartialEq, Serialize)]
153pub struct Graha {
154    /// Body name, or "Ascendant".
155    pub name: &'static str,
156    /// Sidereal longitude, degrees.
157    pub longitude: f64,
158    /// Sign name, e.g. "Taurus".
159    pub sign: &'static str,
160    /// Degrees within the sign.
161    pub degree: f64,
162    /// Whole-sign house from the Ascendant (1 for the Ascendant itself).
163    pub house: u8,
164    /// Whether the body is retrograde.
165    pub is_retrograde: bool,
166    /// Its nakshatra.
167    pub nakshatra: Nakshatra,
168    /// In the same sign in the navamsha (D9) as in the rashi (D1).
169    pub vargottama: bool,
170}
171
172/// A placement in a divisional chart.
173#[derive(Debug, Clone, PartialEq, Serialize)]
174pub struct VargaPlacement {
175    /// Body name, or "Ascendant".
176    pub name: &'static str,
177    /// Sign name, e.g. "Taurus".
178    pub sign: &'static str,
179    /// Degrees within the varga sign.
180    pub degree: f64,
181    /// Whole-sign house from the varga's Ascendant.
182    pub house: u8,
183}
184
185/// A divisional chart: the Ascendant first, then the grahas.
186#[derive(Debug, Clone, PartialEq, Serialize)]
187pub struct Varga {
188    /// "D1" to "D60".
189    pub code: String,
190    /// Parts per sign.
191    pub division: u8,
192    /// The placements.
193    pub placements: Vec<VargaPlacement>,
194}
195
196/// The Vedic reading of a chart: [`jyotish`].
197#[derive(Debug, Clone, PartialEq, Serialize)]
198pub struct Jyotish {
199    /// Ayanamsa code.
200    pub ayanamsa: &'static str,
201    /// Degrees at the birth.
202    pub ayanamsa_value: f64,
203    /// The Ascendant first, then the grahas (and the outer planets when asked).
204    pub grahas: Vec<Graha>,
205    /// The divisional charts asked for.
206    pub vargas: Vec<Varga>,
207    /// The Vimshottari dasha.
208    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
219/// The sidereal placements, nakshatras, vargas and Vimshottari dasha of `chart` (the
220/// chart JSON, tropical or sidereal under any ayanamsa) born at `birth`, under the
221/// ayanamsa of `options`.
222pub 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    // The chart's own longitudes back to tropical, then to the requested ayanamsa.
234    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); // just before 0°: the end of Revati
365    }
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}