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