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