Skip to main content

astroceleste_engine/
chart.rs

1//! A complete chart: positions, houses, aspects, fixed stars, lots, temperament and
2//! lunar status, in the JSON shape of the reference's `calculate_chart_data`.
3
4use serde::{Serialize, Serializer};
5use serde_json::Value;
6
7use crate::aspects::{natal_aspects, Aspect, OrbSettings, Point};
8use crate::chinese::calendar::{chinese_calendar, ChineseCalendar};
9use crate::dignities::{
10    antiscia, condition, is_diurnal, receptions, AntisciaContact, Body, Condition, DignityScheme,
11    Reception,
12};
13use crate::ephemeris::KernelSet;
14use crate::error::EngineError;
15use crate::fixed_stars::{fixed_stars, FixedStarPosition};
16use crate::horary::{planetary_hours, PlanetaryHours};
17use crate::houses::{calculate_houses, HouseSystem, Houses};
18use crate::instant::UtcInstant;
19use crate::lots::{arabic_parts, Lot};
20use crate::lunar::{lunar_status, LunarStatus};
21use crate::planets::calculate_planets;
22use crate::symbolic::symbolic_degree_number;
23use crate::temperament::{temperament, Temperament};
24use crate::zodiac::{
25    ayanamsa, ayanamsa_info, determine_house, longitude_to_zodiac, AyanamsaInfo, DEFAULT_AYANAMSA,
26};
27
28/// Every body a complete chart carries; missing ones are reported in
29/// `unavailable_bodies` rather than silently dropped.
30const EXPECTED_BODIES: [&str; 16] = [
31    "Sun",
32    "Moon",
33    "Mercury",
34    "Venus",
35    "Mars",
36    "Jupiter",
37    "Saturn",
38    "Uranus",
39    "Neptune",
40    "Pluto",
41    "North Node",
42    "South Node",
43    "Chiron",
44    "Lilith",
45    "Ascendant",
46    "Midheaven",
47];
48
49/// What to compute.
50#[derive(Debug, Clone)]
51pub struct ChartRequest<'a> {
52    /// Moment of the chart.
53    pub instant: UtcInstant,
54    /// Geographic latitude in degrees, north positive.
55    pub latitude: f64,
56    /// Geographic longitude in degrees, east positive.
57    pub longitude: f64,
58    /// House system code, echoed as given; its first letter selects the system.
59    pub house_system: &'a str,
60    /// "tropical" or "sidereal" (anything else is tropical).
61    pub zodiac_type: &'a str,
62    /// Ayanamsa code for sidereal charts (unknown codes use the default).
63    pub ayanamsa: &'a str,
64    /// Caller orb settings, merged over the defaults.
65    pub orb_settings: Option<&'a Value>,
66    /// Doctrine of triplicities and bounds: "lilly" (default) or "dorothean".
67    pub dignity_scheme: &'a str,
68    /// Also compute the [`ChineseCalendar`] of the moment (default `false`): a few dozen
69    /// solar-term and new-moon searches.
70    pub chinese_calendar: bool,
71}
72
73impl<'a> ChartRequest<'a> {
74    /// A tropical Placidus request with default orbs.
75    pub fn new(instant: UtcInstant, latitude: f64, longitude: f64) -> Self {
76        ChartRequest {
77            instant,
78            latitude,
79            longitude,
80            house_system: "P",
81            zodiac_type: "tropical",
82            ayanamsa: DEFAULT_AYANAMSA,
83            orb_settings: None,
84            dignity_scheme: "lilly",
85            chinese_calendar: false,
86        }
87    }
88}
89
90/// A planet, lunar point or angle placed in the chart.
91#[derive(Debug, Clone, PartialEq, Serialize)]
92pub struct Placement {
93    /// Body or angle name, e.g. "Sun", "North Node", "Ascendant".
94    pub name: &'static str,
95    /// Glyph, e.g. "☉" ("ASC" and "MC" for the angles).
96    pub symbol: &'static str,
97    /// Zodiac sign name, e.g. "Taurus".
98    pub sign: &'static str,
99    /// Zodiac sign glyph, e.g. "♉".
100    pub sign_symbol: &'static str,
101    /// Whole degrees within the sign (0-29).
102    pub degree: i64,
103    /// Arc minutes past `degree` (0-59).
104    pub minute: i64,
105    /// Ecliptic longitude in degrees [0, 360), tropical or sidereal as requested.
106    pub ecliptic_longitude: f64,
107    /// House (1-12) the point falls in.
108    pub house: u8,
109    /// Apparent speed in ecliptic longitude, degrees per day.
110    pub speed: f64,
111    /// Whether the apparent motion is retrograde.
112    pub is_retrograde: bool,
113    /// Symbolic degree (1-30) within the sign, as used by degree symbolism.
114    pub symbolic_degree: i64,
115    /// Essential dignity, sect, phase to the Sun and speed: the seven planets only.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub condition: Option<Condition>,
118}
119
120/// The cusp of one house.
121#[derive(Debug, Clone, PartialEq, Serialize)]
122pub struct HouseCusp {
123    /// House number (1-12).
124    pub house_number: u8,
125    /// Zodiac sign name, e.g. "Taurus".
126    pub sign: &'static str,
127    /// Zodiac sign glyph, e.g. "♉".
128    pub sign_symbol: &'static str,
129    /// Whole degrees within the sign (0-29).
130    pub degree: i64,
131    /// Arc minutes past `degree` (0-59).
132    pub minute: i64,
133    /// Ecliptic longitude in degrees [0, 360), tropical or sidereal as requested.
134    pub ecliptic_longitude: f64,
135    /// Symbolic degree (1-30) within the sign, as used by degree symbolism.
136    pub symbolic_degree: i64,
137}
138
139/// A complete chart (`calculate_chart_data`); serializes to the API's JSON.
140#[derive(Debug, Clone, PartialEq, Serialize)]
141pub struct Chart {
142    /// House system code, echoed from the request.
143    pub house_system: String,
144    /// "tropical" or "sidereal".
145    pub zodiac_type: &'static str,
146    /// Ayanamsa code; `None` for tropical charts.
147    pub ayanamsa: Option<&'static str>,
148    /// Human-readable ayanamsa name; `None` for tropical charts.
149    pub ayanamsa_name: Option<&'static str>,
150    /// Ayanamsa at the chart instant, in degrees; `None` for tropical charts.
151    pub ayanamsa_value: Option<f64>,
152    /// `ayanamsa_value` as degrees, minutes and seconds; `None` for tropical charts.
153    pub ayanamsa_formatted: Option<String>,
154    /// Mean precession rate used for the fixed stars, arcseconds per year.
155    pub precession_rate_arcsec_yr: f64,
156    /// Orb settings in effect: the defaults merged with the request's.
157    pub orb_settings: Value,
158    /// Planets, lunar points, Chiron, Lilith and the angles.
159    pub planets: Vec<Placement>,
160    /// Expected bodies that could not be computed (e.g. Chiron outside its table).
161    pub unavailable_bodies: Vec<&'static str>,
162    /// The twelve house cusps.
163    pub houses: Vec<HouseCusp>,
164    /// Aspects between chart points, then fixed-star conjunctions.
165    pub aspects: Vec<Aspect>,
166    /// Fixed stars conjunct a chart point.
167    pub fixed_stars: Vec<FixedStarPosition>,
168    /// The Arabic parts (lots).
169    pub arabic_parts: Vec<Lot>,
170    /// Temperament assessment.
171    pub temperament: Temperament,
172    /// Lunar phase and status; `None` (serialized as `{}`) without a Moon.
173    #[serde(serialize_with = "empty_object_if_none")]
174    pub lunar_status: Option<LunarStatus>,
175    /// "diurnal" (the Sun above the horizon) or "nocturnal"; `None` without Sun and Ascendant.
176    pub sect: Option<&'static str>,
177    /// Doctrine of triplicities and bounds used for `condition` and `receptions`.
178    pub dignity_scheme: &'static str,
179    /// Receptions among the seven planets.
180    pub receptions: Vec<Reception>,
181    /// Antiscia and contra-antiscia among the seven planets and the angles.
182    pub antiscia: Vec<AntisciaContact>,
183    /// Planetary day and hour; absent from transit skies.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub planetary_hours: Option<PlanetaryHours>,
186    /// The Chinese calendar of the moment, when requested and the kernels cover it.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub chinese_calendar: Option<ChineseCalendar>,
189}
190
191fn empty_object_if_none<S: Serializer>(v: &Option<LunarStatus>, s: S) -> Result<S::Ok, S::Error> {
192    match v {
193        Some(status) => status.serialize(s),
194        None => serde_json::Map::new().serialize(s),
195    }
196}
197
198impl Chart {
199    /// The chart's planets and angles as named ecliptic points.
200    pub fn points(&self) -> Vec<Point<'_>> {
201        self.planets
202            .iter()
203            .map(|p| Point {
204                name: p.name,
205                longitude: p.ecliptic_longitude,
206            })
207            .collect()
208    }
209}
210
211fn symbol_of(name: &str) -> &'static str {
212    match name {
213        "Sun" => "☉",
214        "Moon" => "☽",
215        "Mercury" => "☿",
216        "Venus" => "♀",
217        "Mars" => "♂",
218        "Jupiter" => "♃",
219        "Saturn" => "♄",
220        "Uranus" => "♅",
221        "Neptune" => "♆",
222        "Pluto" => "♇",
223        "North Node" => "☊",
224        "South Node" => "☋",
225        "Chiron" => "⚷",
226        "Lilith" => "⚸",
227        "Ascendant" => "ASC",
228        "Midheaven" => "MC",
229        _ => "",
230    }
231}
232
233/// The positions part of a chart: placements and house cusps, without aspects, fixed
234/// stars, lots or temperament. Electional searches evaluate thousands of these.
235pub(crate) struct Sky {
236    pub zodiac_type: &'static str,
237    pub is_sidereal: bool,
238    pub ayanamsa_code: &'static str,
239    pub info: AyanamsaInfo,
240    pub jd: f64,
241    /// Cusps 1-12, degrees.
242    pub cusps: Vec<f64>,
243    pub house_cusps: Vec<HouseCusp>,
244    pub planets: Vec<Placement>,
245}
246
247/// Placements and houses of a request (the first half of [`calculate_chart`]).
248pub(crate) fn sky(kernels: &KernelSet, req: &ChartRequest) -> Result<Sky, EngineError> {
249    let zodiac_type = match req.zodiac_type.trim().to_lowercase().as_str() {
250        "sidereal" => "sidereal",
251        _ => "tropical",
252    };
253    let is_sidereal = zodiac_type == "sidereal";
254    let ayanamsa_code = ayanamsa(req.ayanamsa).code;
255
256    let jd = req.instant.julian_day();
257    let info = ayanamsa_info(jd, ayanamsa_code);
258    let shift = if is_sidereal { info.value } else { 0.0 };
259
260    let engine = calculate_planets(kernels, jd, shift)?;
261    let houses = calculate_houses(
262        jd,
263        req.latitude,
264        req.longitude,
265        HouseSystem::from_code(req.house_system),
266        shift,
267    );
268    let cusps = houses.cusps.to_vec();
269
270    let house_cusps: Vec<HouseCusp> = houses
271        .cusps
272        .iter()
273        .enumerate()
274        .map(|(i, &lon)| {
275            let z = longitude_to_zodiac(lon);
276            HouseCusp {
277                house_number: i as u8 + 1,
278                sign: z.sign.name,
279                sign_symbol: z.sign.symbol,
280                degree: z.degree,
281                minute: z.minute,
282                ecliptic_longitude: lon,
283                symbolic_degree: symbolic_degree_number(z.degree, z.minute),
284            }
285        })
286        .collect();
287
288    let raw: Vec<(&'static str, f64, f64, bool)> = engine
289        .bodies
290        .iter()
291        .map(|b| (b.name, b.longitude, b.speed, b.is_retrograde))
292        .collect();
293    let planets = placements(raw, &houses);
294
295    Ok(Sky {
296        zodiac_type,
297        is_sidereal,
298        ayanamsa_code,
299        info,
300        jd,
301        cusps,
302        house_cusps,
303        planets,
304    })
305}
306
307/// Placements of bodies given as (name, longitude, speed, retrograde), followed by the
308/// Ascendant and the Midheaven, with their houses.
309pub(crate) fn placements(
310    mut raw: Vec<(&'static str, f64, f64, bool)>,
311    houses: &Houses,
312) -> Vec<Placement> {
313    raw.push(("Ascendant", houses.ascendant, 0.0, false));
314    raw.push(("Midheaven", houses.midheaven, 0.0, false));
315    raw.iter()
316        .map(|&(name, lon, speed, is_retrograde)| {
317            let z = longitude_to_zodiac(lon);
318            Placement {
319                name,
320                symbol: symbol_of(name),
321                sign: z.sign.name,
322                sign_symbol: z.sign.symbol,
323                degree: z.degree,
324                minute: z.minute,
325                ecliptic_longitude: lon,
326                house: determine_house(lon, &houses.cusps),
327                speed,
328                is_retrograde,
329                symbolic_degree: symbolic_degree_number(z.degree, z.minute),
330                condition: None,
331            }
332        })
333        .collect()
334}
335
336/// Set the condition of the seven planets, which needs the Sun and the Ascendant.
337fn add_conditions(planets: &mut [Placement], scheme: DignityScheme) -> Option<&'static str> {
338    let at = |name: &str| {
339        planets
340            .iter()
341            .find(|p| p.name == name)
342            .map(|p| p.ecliptic_longitude)
343    };
344    let (sun, asc) = (at("Sun")?, at("Ascendant")?);
345    for planet in planets.iter_mut() {
346        planet.condition = condition(Body::from(&*planet), sun, asc, scheme);
347    }
348    Some(if is_diurnal(sun, asc) {
349        "diurnal"
350    } else {
351        "nocturnal"
352    })
353}
354
355/// Compute a chart (`calculate_chart_data`), with its planetary day and hour.
356pub fn calculate_chart(kernels: &KernelSet, req: &ChartRequest) -> Result<Chart, EngineError> {
357    let mut chart = chart_without_hours(kernels, req)?;
358    chart.planetary_hours = Some(planetary_hours(
359        kernels,
360        req.instant,
361        req.latitude,
362        req.longitude,
363    ));
364    Ok(chart)
365}
366
367/// A chart without its planetary hours, which cost a sunrise search: for transit skies,
368/// and for the callers that compute the hours themselves.
369pub(crate) fn chart_without_hours(
370    kernels: &KernelSet,
371    req: &ChartRequest,
372) -> Result<Chart, EngineError> {
373    let orbs = OrbSettings::merge(req.orb_settings)?;
374    let scheme = DignityScheme::from_code(req.dignity_scheme);
375    let Sky {
376        zodiac_type,
377        is_sidereal,
378        ayanamsa_code,
379        info,
380        jd,
381        cusps,
382        house_cusps,
383        mut planets,
384    } = sky(kernels, req)?;
385    let sect = add_conditions(&mut planets, scheme);
386    let points: Vec<Point> = planets
387        .iter()
388        .map(|p| Point {
389            name: p.name,
390            longitude: p.ecliptic_longitude,
391        })
392        .collect();
393
394    let mut aspects = natal_aspects(&points, &orbs)?;
395    let (stars, star_aspects) = fixed_stars(
396        jd,
397        &cusps,
398        &points,
399        orbs.fixed_star_orb()?,
400        is_sidereal.then_some(info.value),
401    );
402    aspects.extend(star_aspects);
403
404    let lots = arabic_parts(&points, &cusps);
405    let temperament = temperament(&planets, &aspects);
406    let lunar = lunar_status(&planets);
407    let bodies: Vec<Body> = planets.iter().map(Body::from).collect();
408    let aspect_between = |a: &str, b: &str| {
409        aspects
410            .iter()
411            .find(|x| (x.body1 == a && x.body2 == b) || (x.body1 == b && x.body2 == a))
412            .map(|x| x.aspect_type)
413    };
414    let chart_receptions = receptions(&bodies, aspect_between, scheme);
415    let chart_antiscia = antiscia(&planets);
416
417    let mut unavailable: Vec<&'static str> = EXPECTED_BODIES
418        .iter()
419        .copied()
420        .filter(|name| !planets.iter().any(|p| p.name == *name))
421        .collect();
422    unavailable.sort_unstable();
423
424    Ok(Chart {
425        house_system: req.house_system.to_string(),
426        zodiac_type,
427        ayanamsa: is_sidereal.then_some(ayanamsa_code),
428        ayanamsa_name: is_sidereal.then_some(info.name),
429        ayanamsa_value: is_sidereal.then_some(info.value),
430        ayanamsa_formatted: is_sidereal.then(|| info.formatted.clone()),
431        precession_rate_arcsec_yr: info.precession_rate_arcsec_yr,
432        orb_settings: orbs.to_value(),
433        planets,
434        unavailable_bodies: unavailable,
435        houses: house_cusps,
436        aspects,
437        fixed_stars: stars,
438        arabic_parts: lots,
439        temperament,
440        lunar_status: lunar,
441        sect,
442        dignity_scheme: scheme.code(),
443        receptions: chart_receptions,
444        antiscia: chart_antiscia,
445        planetary_hours: None,
446        chinese_calendar: if req.chinese_calendar {
447            chinese_calendar(kernels, req.instant)?
448        } else {
449            None
450        },
451    })
452}