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