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};
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, 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/// Compute a chart (`calculate_chart_data`).
204pub fn calculate_chart(kernels: &KernelSet, req: &ChartRequest) -> Result<Chart, EngineError> {
205    let zodiac_type = match req.zodiac_type.trim().to_lowercase().as_str() {
206        "sidereal" => "sidereal",
207        _ => "tropical",
208    };
209    let is_sidereal = zodiac_type == "sidereal";
210    let ayanamsa_code = ayanamsa(req.ayanamsa).code;
211    let orbs = OrbSettings::merge(req.orb_settings)?;
212
213    let jd = req.instant.julian_day();
214    let info = ayanamsa_info(jd, ayanamsa_code);
215    let shift = if is_sidereal { info.value } else { 0.0 };
216
217    let engine = calculate_planets(kernels, jd, shift)?;
218    let houses = calculate_houses(
219        jd,
220        req.latitude,
221        req.longitude,
222        HouseSystem::from_code(req.house_system),
223        shift,
224    );
225    let cusps = houses.cusps.to_vec();
226
227    let house_cusps: Vec<HouseCusp> = houses
228        .cusps
229        .iter()
230        .enumerate()
231        .map(|(i, &lon)| {
232            let z = longitude_to_zodiac(lon);
233            HouseCusp {
234                house_number: i as u8 + 1,
235                sign: z.sign.name,
236                sign_symbol: z.sign.symbol,
237                degree: z.degree,
238                minute: z.minute,
239                ecliptic_longitude: lon,
240                symbolic_degree: symbolic_degree_number(z.degree, z.minute),
241            }
242        })
243        .collect();
244
245    let mut raw: Vec<(&'static str, f64, f64, bool)> = engine
246        .bodies
247        .iter()
248        .map(|b| (b.name, b.longitude, b.speed, b.is_retrograde))
249        .collect();
250    raw.push(("Ascendant", houses.ascendant, 0.0, false));
251    raw.push(("Midheaven", houses.midheaven, 0.0, false));
252
253    let planets: Vec<Placement> = raw
254        .iter()
255        .map(|&(name, lon, speed, is_retrograde)| {
256            let z = longitude_to_zodiac(lon);
257            Placement {
258                name,
259                symbol: symbol_of(name),
260                sign: z.sign.name,
261                sign_symbol: z.sign.symbol,
262                degree: z.degree,
263                minute: z.minute,
264                ecliptic_longitude: lon,
265                house: determine_house(lon, &cusps),
266                speed,
267                is_retrograde,
268                symbolic_degree: symbolic_degree_number(z.degree, z.minute),
269            }
270        })
271        .collect();
272    let points: Vec<Point> = planets
273        .iter()
274        .map(|p| Point {
275            name: p.name,
276            longitude: p.ecliptic_longitude,
277        })
278        .collect();
279
280    let mut aspects = natal_aspects(&points, &orbs)?;
281    let (stars, star_aspects) = fixed_stars(
282        jd,
283        &cusps,
284        &points,
285        orbs.fixed_star_orb()?,
286        is_sidereal.then_some(info.value),
287    );
288    aspects.extend(star_aspects);
289
290    let lots = arabic_parts(&points, &cusps);
291    let temperament = temperament(&planets, &aspects);
292    let lunar = lunar_status(&planets);
293
294    let mut unavailable: Vec<&'static str> = EXPECTED_BODIES
295        .iter()
296        .copied()
297        .filter(|name| !planets.iter().any(|p| p.name == *name))
298        .collect();
299    unavailable.sort_unstable();
300
301    Ok(Chart {
302        house_system: req.house_system.to_string(),
303        zodiac_type,
304        ayanamsa: is_sidereal.then_some(ayanamsa_code),
305        ayanamsa_name: is_sidereal.then_some(info.name),
306        ayanamsa_value: is_sidereal.then_some(info.value),
307        ayanamsa_formatted: is_sidereal.then(|| info.formatted.clone()),
308        precession_rate_arcsec_yr: info.precession_rate_arcsec_yr,
309        orb_settings: orbs.to_value(),
310        planets,
311        unavailable_bodies: unavailable,
312        houses: house_cusps,
313        aspects,
314        fixed_stars: stars,
315        arabic_parts: lots,
316        temperament,
317        lunar_status: lunar,
318    })
319}