Skip to main content

astroceleste_engine/
derived.rs

1//! Charts built on other charts (`charts/calc/derived.py`): transits against a natal
2//! chart, synastry between two charts, and derived (turned) charts.
3
4use serde::Serialize;
5use serde_json::{json, Map, Value};
6
7use crate::aspects::{cross_aspects, points_from_json, CrossAspect};
8use crate::catalog::{DERIVED_HOUSE_MEANINGS, ROOT_HOUSE_THEMES};
9use crate::chart::{chart_without_hours, ChartRequest, HouseCusp, Placement};
10use crate::ephemeris::KernelSet;
11use crate::error::EngineError;
12use crate::zodiac::ZODIAC_SIGNS;
13
14/// Transits to a natal chart (`calculate_transit_chart`).
15#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct TransitChart {
17    /// Transiting bodies and angles.
18    pub transit_planets: Vec<Placement>,
19    /// House cusps at the transit time and place.
20    pub transit_houses: Vec<HouseCusp>,
21    /// Aspects from transiting (overlay) to natal (base) points.
22    pub cross_aspects: Vec<CrossAspect>,
23    /// Transit instant, ISO 8601 UTC.
24    pub transit_datetime: String,
25    /// Transit latitude in degrees, north positive.
26    pub transit_lat: f64,
27    /// Transit longitude in degrees, east positive.
28    pub transit_lon: f64,
29    /// "tropical" or "sidereal".
30    pub zodiac_type: &'static str,
31    /// Ayanamsa code; `None` for tropical charts.
32    pub ayanamsa: Option<&'static str>,
33    /// Human-readable ayanamsa name; `None` for tropical charts.
34    pub ayanamsa_name: Option<&'static str>,
35    /// Ayanamsa at the chart instant, in degrees; `None` for tropical charts.
36    pub ayanamsa_value: Option<f64>,
37    /// `ayanamsa_value` as degrees, minutes and seconds; `None` for tropical charts.
38    pub ayanamsa_formatted: Option<String>,
39}
40
41/// The sky at `req` (relocatable), with its cross-aspects to `natal_planets`
42/// (`calculate_transit_chart`). `req.chinese_calendar` and `req.vedic_calendar` are
43/// ignored: a transit chart has no calendar.
44pub fn calculate_transit_chart(
45    kernels: &KernelSet,
46    natal_planets: &Value,
47    req: &ChartRequest,
48) -> Result<TransitChart, EngineError> {
49    let natal = points_from_json(natal_planets)?;
50    let req = &ChartRequest {
51        chinese_calendar: false,
52        vedic_calendar: false,
53        ..req.clone()
54    };
55    let chart = chart_without_hours(kernels, req)?;
56    let aspects = cross_aspects(&natal, &chart.points(), req.orb_settings)?;
57    Ok(TransitChart {
58        cross_aspects: aspects,
59        transit_datetime: req.instant.isoformat(),
60        transit_lat: req.latitude,
61        transit_lon: req.longitude,
62        zodiac_type: chart.zodiac_type,
63        ayanamsa: chart.ayanamsa,
64        ayanamsa_name: chart.ayanamsa_name,
65        ayanamsa_value: chart.ayanamsa_value,
66        ayanamsa_formatted: chart.ayanamsa_formatted,
67        transit_planets: chart.planets,
68        transit_houses: chart.houses,
69    })
70}
71
72/// Synastry between two charts.
73#[derive(Debug, Clone, PartialEq, Serialize)]
74pub struct Synastry {
75    /// Aspects from chart B (overlay) to chart A (base).
76    pub cross_aspects: Vec<CrossAspect>,
77}
78
79/// Cross-aspects from chart B (overlay) to chart A (base) (`calculate_synastry_chart`).
80pub fn calculate_synastry(
81    chart_a_planets: &Value,
82    chart_b_planets: &Value,
83    orb_settings: Option<&Value>,
84) -> Result<Synastry, EngineError> {
85    let a = points_from_json(chart_a_planets)?;
86    let b = points_from_json(chart_b_planets)?;
87    Ok(Synastry {
88        cross_aspects: cross_aspects(&a, &b, orb_settings)?,
89    })
90}
91
92/// Python `dict.get(key, default)`.
93fn get(obj: &Map<String, Value>, key: &str, default: Value) -> Value {
94    obj.get(key).cloned().unwrap_or(default)
95}
96
97/// Python truthiness of a JSON value.
98fn truthy(v: Option<&Value>) -> bool {
99    match v {
100        None | Some(Value::Null) => false,
101        Some(Value::Bool(b)) => *b,
102        Some(Value::Number(n)) => n.as_f64() != Some(0.0),
103        Some(Value::String(s)) => !s.is_empty(),
104        Some(Value::Array(a)) => !a.is_empty(),
105        Some(Value::Object(o)) => !o.is_empty(),
106    }
107}
108
109fn house_number_is(h: &Value, n: i64) -> bool {
110    h.get("house_number").and_then(Value::as_f64) == Some(n as f64)
111}
112
113/// `((radix_house - root) % 12) + 1` for a stored house number (default 1).
114fn turned_house(item: &Map<String, Value>, root: i64) -> (Value, i64) {
115    let radix = get(item, "house", json!(1));
116    let n = radix.as_f64().unwrap_or(1.0) as i64;
117    (radix, (n - root).rem_euclid(12) + 1)
118}
119
120fn turn_items(base: &Map<String, Value>, key: &str, root: i64) -> Result<Vec<Value>, EngineError> {
121    let items = match base.get(key) {
122        None | Some(Value::Null) => return Ok(Vec::new()),
123        Some(Value::Array(items)) => items,
124        Some(other) => {
125            return Err(EngineError::InvalidInput(format!(
126                "{key} must be a list, got {other}"
127            )))
128        }
129    };
130    items
131        .iter()
132        .map(|item| {
133            let mut copy = item.as_object().cloned().ok_or_else(|| {
134                EngineError::InvalidInput(format!("{key} entries must be objects"))
135            })?;
136            let (radix, turned) = turned_house(&copy, root);
137            copy.insert("radix_house".into(), radix);
138            copy.insert("house".into(), json!(turned));
139            Ok(Value::Object(copy))
140        })
141        .collect()
142}
143
144/// A derived (turned) chart: radix house `root` (1-12) becomes the first house; cusps,
145/// angles and house placements follow, with the meaning of each derived house
146/// (`calculate_derived_chart_data`). Works on a stored chart payload, keeping every
147/// field it does not turn.
148pub fn calculate_derived_chart(
149    base: &Value,
150    root: i64,
151    custom_name: Option<&str>,
152) -> Result<Value, EngineError> {
153    let base = base
154        .as_object()
155        .ok_or_else(|| EngineError::InvalidInput("base chart must be an object".into()))?;
156    let root = if (1..=12).contains(&root) { root } else { 1 };
157    let meanings = &DERIVED_HOUSE_MEANINGS[root as usize - 1];
158
159    let stored_houses = base
160        .get("houses")
161        .and_then(Value::as_array)
162        .cloned()
163        .unwrap_or_default();
164    let base_houses: Vec<Value> = if stored_houses.len() >= 12 {
165        stored_houses
166    } else {
167        (0..12)
168            .map(|i| {
169                json!({
170                    "house_number": i + 1,
171                    "degree": i * 30,
172                    "minute": 0,
173                    "sign": ZODIAC_SIGNS[i].name,
174                    "sign_symbol": ZODIAC_SIGNS[i].symbol,
175                    "ecliptic_longitude": (i * 30) as f64,
176                })
177            })
178            .collect()
179    };
180    let house_obj = |r: i64| -> Map<String, Value> {
181        base_houses
182            .iter()
183            .find(|h| house_number_is(h, r))
184            .unwrap_or(&base_houses[r as usize - 1])
185            .as_object()
186            .cloned()
187            .unwrap_or_default()
188    };
189
190    let mut derived_houses: Vec<Map<String, Value>> = Vec::with_capacity(12);
191    for d in 1..=12_i64 {
192        let r = (root - 1 + d - 1) % 12 + 1;
193        let radix = house_obj(r);
194        let meaning = meanings[d as usize - 1];
195        let mut h = Map::new();
196        h.insert("house_number".into(), json!(d));
197        h.insert("radix_house_number".into(), json!(r));
198        h.insert("sign".into(), get(&radix, "sign", json!("")));
199        h.insert("sign_symbol".into(), get(&radix, "sign_symbol", json!("")));
200        h.insert("degree".into(), get(&radix, "degree", json!(0)));
201        h.insert("minute".into(), get(&radix, "minute", json!(0)));
202        h.insert(
203            "ecliptic_longitude".into(),
204            get(&radix, "ecliptic_longitude", json!(0.0)),
205        );
206        h.insert(
207            "symbolic_degree".into(),
208            get(&radix, "symbolic_degree", Value::Null),
209        );
210        h.insert(
211            "degree_symbol".into(),
212            get(&radix, "degree_symbol", Value::Null),
213        );
214        h.insert(
215            "degree_symbols".into(),
216            get(&radix, "degree_symbols", Value::Null),
217        );
218        h.insert("meaning_en".into(), json!(meaning.en));
219        h.insert("meaning_it".into(), json!(meaning.it));
220        h.insert(
221            "derived_label".into(),
222            json!(format!("House {d} (Radix {r})")),
223        );
224        derived_houses.push(h);
225    }
226
227    let mut planets = Vec::new();
228    if let Some(Value::Array(items)) = base.get("planets") {
229        for p in items {
230            let mut copy = p.as_object().cloned().ok_or_else(|| {
231                EngineError::InvalidInput("planets entries must be objects".into())
232            })?;
233            let (radix, turned) = turned_house(&copy, root);
234            copy.insert("radix_house".into(), radix);
235            let angle = match copy.get("name").and_then(Value::as_str) {
236                Some("Ascendant") => Some((0, 1)),
237                Some("Midheaven") => Some((9, 10)),
238                _ => None,
239            };
240            match angle {
241                Some((index, house)) => {
242                    let cusp = &derived_houses[index];
243                    for key in [
244                        "sign",
245                        "sign_symbol",
246                        "degree",
247                        "minute",
248                        "ecliptic_longitude",
249                    ] {
250                        copy.insert(key.into(), cusp[key].clone());
251                    }
252                    copy.insert("house".into(), json!(house));
253                    if truthy(cusp.get("symbolic_degree")) {
254                        for key in ["symbolic_degree", "degree_symbol", "degree_symbols"] {
255                            copy.insert(key.into(), cusp[key].clone());
256                        }
257                    }
258                }
259                None => {
260                    copy.insert("house".into(), json!(turned));
261                }
262            }
263            planets.push(Value::Object(copy));
264        }
265    }
266    let fixed_stars = turn_items(base, "fixed_stars", root)?;
267    let lots = turn_items(base, "arabic_parts", root)?;
268
269    let (theme_en, theme_it) = ROOT_HOUSE_THEMES[root as usize - 1];
270    let root_cusp = base_houses
271        .iter()
272        .find(|h| house_number_is(h, root))
273        .unwrap_or(&base_houses[0])
274        .as_object()
275        .cloned()
276        .unwrap_or_default();
277    let base_name = match base.get("user_name") {
278        Some(v) if truthy(Some(v)) => v.clone(),
279        _ => json!("Radix Chart"),
280    };
281    let name = match custom_name {
282        Some(n) if !n.is_empty() => n.to_string(),
283        _ => format!(
284            "{} [Derived H{root} - {theme_en}]",
285            base_name
286                .as_str()
287                .map_or_else(|| base_name.to_string(), str::to_string)
288        ),
289    };
290    let meanings_map: Map<String, Value> = meanings
291        .iter()
292        .enumerate()
293        .map(|(i, m)| ((i + 1).to_string(), json!({"en": m.en, "it": m.it})))
294        .collect();
295    let at = |key: &str| root_cusp.get(key).cloned().unwrap_or(Value::Null);
296
297    let derived_data = json!({
298        "derived_root_house": root,
299        "derived_from_id": get(base, "id", Value::Null),
300        "derived_from_name": base_name.clone(),
301        "root_house_theme": {"name_en": theme_en, "name_it": theme_it},
302        "root_cusp": {
303            "house_number": root,
304            "sign": at("sign"),
305            "degree": at("degree"),
306            "minute": at("minute"),
307            "ecliptic_longitude": at("ecliptic_longitude"),
308        },
309        "meanings": meanings_map,
310    });
311
312    let mut out = Map::new();
313    let mut put = |k: &str, v: Value| {
314        out.insert(k.to_string(), v);
315    };
316    put("id", Value::Null);
317    put("chart_type", json!("DERIVED"));
318    put("user_name", json!(name));
319    put("birth_datetime", get(base, "birth_datetime", Value::Null));
320    put("local_datetime", get(base, "local_datetime", Value::Null));
321    put("timezone", get(base, "timezone", json!("UTC")));
322    put(
323        "timezone_offset",
324        get(base, "timezone_offset", json!("+00:00")),
325    );
326    put("timezone_abbr", get(base, "timezone_abbr", json!("UTC")));
327    put("is_dst", get(base, "is_dst", json!(false)));
328    put("dst_offset", get(base, "dst_offset", json!("+00:00")));
329    put("raw_offset", get(base, "raw_offset", json!("+00:00")));
330    put("house_system", get(base, "house_system", json!("P")));
331    put("zodiac_type", get(base, "zodiac_type", json!("tropical")));
332    put(
333        "zodiac_type_label",
334        get(base, "zodiac_type_label", json!("Tropical")),
335    );
336    put("ayanamsa", get(base, "ayanamsa", Value::Null));
337    put("ayanamsa_name", get(base, "ayanamsa_name", Value::Null));
338    put("ayanamsa_value", get(base, "ayanamsa_value", Value::Null));
339    put(
340        "ayanamsa_formatted",
341        get(base, "ayanamsa_formatted", json!("")),
342    );
343    put("orb_settings", get(base, "orb_settings", json!({})));
344    put("coordinates", get(base, "coordinates", json!({})));
345    put("planets", Value::Array(planets));
346    put(
347        "houses",
348        Value::Array(derived_houses.into_iter().map(Value::Object).collect()),
349    );
350    put("aspects", get(base, "aspects", json!([])));
351    put("fixed_stars", Value::Array(fixed_stars));
352    put("arabic_parts", Value::Array(lots));
353    put("temperament", get(base, "temperament", json!({})));
354    put("lunar_status", get(base, "lunar_status", json!({})));
355    put("derived_from_id", get(base, "id", Value::Null));
356    put("derived_from_name", base_name);
357    put("derived_house", json!(root));
358    put("derived_data", derived_data);
359    Ok(Value::Object(out))
360}