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