Skip to main content

astroceleste_engine/
aspects.rs

1//! Aspects, orbs and cross-aspects (`charts/calc/aspects.py`).
2
3use serde::Serialize;
4use serde_json::{json, Map, Value};
5
6use crate::error::EngineError;
7use crate::pyfloat;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AspectRule {
11    pub name: &'static str,
12    pub angle: f64,
13    pub symbol: &'static str,
14    pub orb: f64,
15    pub is_major: bool,
16}
17
18pub const ASPECT_RULES: [AspectRule; 7] = [
19    AspectRule {
20        name: "Conjunction",
21        angle: 0.0,
22        symbol: "☌",
23        orb: 8.0,
24        is_major: true,
25    },
26    AspectRule {
27        name: "Sextile",
28        angle: 60.0,
29        symbol: "⚹",
30        orb: 6.0,
31        is_major: true,
32    },
33    AspectRule {
34        name: "Square",
35        angle: 90.0,
36        symbol: "□",
37        orb: 7.0,
38        is_major: true,
39    },
40    AspectRule {
41        name: "Trine",
42        angle: 120.0,
43        symbol: "△",
44        orb: 8.0,
45        is_major: true,
46    },
47    AspectRule {
48        name: "Opposition",
49        angle: 180.0,
50        symbol: "☍",
51        orb: 8.0,
52        is_major: true,
53    },
54    AspectRule {
55        name: "Semi-Sextile",
56        angle: 30.0,
57        symbol: "⚺",
58        orb: 2.5,
59        is_major: false,
60    },
61    AspectRule {
62        name: "Quincunx",
63        angle: 150.0,
64        symbol: "⚻",
65        orb: 3.0,
66        is_major: false,
67    },
68];
69
70const ASPECT_WEIGHT_FACTORS: [(&str, f64); 7] = [
71    ("Conjunction", 1.0),
72    ("Opposition", 1.0),
73    ("Trine", 1.0),
74    ("Square", 0.875),
75    ("Sextile", 0.75),
76    ("Semi-Sextile", 0.35),
77    ("Quincunx", 0.4),
78];
79
80/// `DEFAULT_ORB_SETTINGS`, in the reference's key order.
81pub fn default_orb_settings() -> Map<String, Value> {
82    let value = json!({
83        "method": "moiety_hybrid",
84        "include_minor_aspects": false,
85        "include_chiron": false,
86        "include_lilith": false,
87        "aspect_orbs": {
88            "Conjunction": 8.0,
89            "Sextile": 6.0,
90            "Square": 7.0,
91            "Trine": 8.0,
92            "Opposition": 8.0,
93            "Semi-Sextile": 2.5,
94            "Quincunx": 3.0,
95        },
96        "planet_orbs": {
97            "Sun": 15.0,
98            "Moon": 12.0,
99            "Mercury": 7.0,
100            "Venus": 7.0,
101            "Mars": 8.0,
102            "Jupiter": 9.0,
103            "Saturn": 9.0,
104            "Uranus": 5.0,
105            "Neptune": 5.0,
106            "Pluto": 5.0,
107            "North Node": 5.0,
108            "South Node": 5.0,
109            "Chiron": 5.0,
110            "Lilith": 5.0,
111            "Ascendant": 5.0,
112            "Midheaven": 5.0,
113        },
114        "fixed_star_orb": 1.5,
115    });
116    match value {
117        Value::Object(map) => map,
118        _ => unreachable!(),
119    }
120}
121
122/// Python `float(value)` for JSON values: numbers, numeric strings and booleans.
123pub fn py_float(value: &Value) -> Result<f64, EngineError> {
124    match value {
125        Value::Number(n) => n
126            .as_f64()
127            .ok_or_else(|| EngineError::InvalidInput(format!("not a number: {n}"))),
128        Value::Bool(b) => Ok(f64::from(u8::from(*b))),
129        Value::String(s) => {
130            let t = s.trim().to_ascii_lowercase().replace('_', "");
131            match t.as_str() {
132                "inf" | "+inf" | "infinity" | "+infinity" => Ok(f64::INFINITY),
133                "-inf" | "-infinity" => Ok(f64::NEG_INFINITY),
134                "nan" | "+nan" | "-nan" => Ok(f64::NAN),
135                _ => t
136                    .parse()
137                    .map_err(|_| EngineError::InvalidInput(format!("not a number: {s:?}"))),
138            }
139        }
140        other => Err(EngineError::InvalidInput(format!("not a number: {other}"))),
141    }
142}
143
144/// Orb settings merged over the defaults, as the orchestrator and cross-aspects do:
145/// only `method`, `fixed_star_orb`, `aspect_orbs` and `planet_orbs` are taken from the
146/// caller, the orb tables key by key. Values are kept as given (they are echoed back in
147/// the chart) and read with Python `float()` semantics when used.
148#[derive(Debug, Clone, PartialEq)]
149pub struct OrbSettings(Map<String, Value>);
150
151impl Default for OrbSettings {
152    fn default() -> Self {
153        OrbSettings(default_orb_settings())
154    }
155}
156
157impl OrbSettings {
158    pub fn merge(custom: Option<&Value>) -> Result<Self, EngineError> {
159        let mut merged = default_orb_settings();
160        // `if orb_settings and isinstance(orb_settings, dict)`: an empty dict keeps defaults.
161        let Some(Value::Object(custom)) = custom else {
162            return Ok(OrbSettings(merged));
163        };
164        if custom.is_empty() {
165            return Ok(OrbSettings(merged));
166        }
167        if let Some(method) = custom.get("method") {
168            merged.insert("method".into(), method.clone());
169        }
170        let star_orb = match custom.get("fixed_star_orb") {
171            Some(v) => py_float(v)?,
172            None => 1.5,
173        };
174        merged.insert("fixed_star_orb".into(), json!(star_orb));
175        for key in ["aspect_orbs", "planet_orbs"] {
176            let table = merged.get_mut(key).and_then(Value::as_object_mut).unwrap();
177            match custom.get(key) {
178                None => {}
179                Some(Value::Object(given)) => {
180                    for (k, v) in given {
181                        table.insert(k.clone(), v.clone());
182                    }
183                }
184                Some(other) => {
185                    return Err(EngineError::InvalidInput(format!(
186                        "{key} must be an object, got {other}"
187                    )))
188                }
189            }
190        }
191        Ok(OrbSettings(merged))
192    }
193
194    #[cfg(test)]
195    pub fn as_map(&self) -> &Map<String, Value> {
196        &self.0
197    }
198
199    pub fn to_value(&self) -> Value {
200        Value::Object(self.0.clone())
201    }
202
203    fn table(&self, key: &str) -> Option<&Map<String, Value>> {
204        self.0.get(key).and_then(Value::as_object)
205    }
206
207    fn lookup(&self, table: &str, name: &str, default: f64) -> Result<f64, EngineError> {
208        match self.table(table).and_then(|t| t.get(name)) {
209            Some(v) => py_float(v),
210            None => Ok(default),
211        }
212    }
213
214    pub fn fixed_star_orb(&self) -> Result<f64, EngineError> {
215        match self.0.get("fixed_star_orb") {
216            Some(v) => py_float(v),
217            None => Ok(1.5),
218        }
219    }
220
221    /// `get_effective_max_orb` for two bodies and an aspect.
222    pub fn max_orb(&self, body1: &str, body2: &str, aspect: &str) -> Result<f64, EngineError> {
223        let method = self.0.get("method").and_then(Value::as_str).unwrap_or("");
224        let default_aspect_orb = self.lookup("aspect_orbs", aspect, 8.0)?;
225        match method {
226            "moiety_traditional" | "moiety_aspect_weighted" | "moiety_hybrid" => {
227                let moiety = (self.lookup("planet_orbs", body1, 5.0)?
228                    + self.lookup("planet_orbs", body2, 5.0)?)
229                    / 2.0;
230                Ok(match method {
231                    "moiety_aspect_weighted" => {
232                        let conj = self.lookup("aspect_orbs", "Conjunction", 8.0)?;
233                        let has_aspect = self
234                            .table("aspect_orbs")
235                            .is_some_and(|t| t.contains_key(aspect));
236                        let weight = if conj > 0.0 && has_aspect {
237                            self.lookup("aspect_orbs", aspect, 8.0)? / conj
238                        } else {
239                            ASPECT_WEIGHT_FACTORS
240                                .iter()
241                                .find(|(name, _)| *name == aspect)
242                                .map_or(1.0, |(_, w)| *w)
243                        };
244                        pyfloat::round(moiety * weight, 2)
245                    }
246                    "moiety_hybrid" => {
247                        let capped = if default_aspect_orb < moiety {
248                            default_aspect_orb
249                        } else {
250                            moiety
251                        };
252                        pyfloat::round(capped, 2)
253                    }
254                    _ => pyfloat::round(moiety, 2),
255                })
256            }
257            _ => Ok(default_aspect_orb),
258        }
259    }
260}
261
262/// An aspect between two chart points. Fixed-star conjunctions carry no `is_major`.
263#[derive(Debug, Clone, PartialEq, Serialize)]
264pub struct Aspect {
265    /// First point.
266    pub body1: String,
267    /// Second point.
268    pub body2: String,
269    /// Aspect name, e.g. "Trine".
270    pub aspect_type: &'static str,
271    /// Aspect glyph, e.g. "△".
272    pub symbol: &'static str,
273    /// Exact angle of the aspect, in degrees.
274    pub angle: f64,
275    /// Distance from exactness, in degrees (rounded to 0.01).
276    pub orb: f64,
277    /// Largest orb allowed for this pair and aspect, in degrees.
278    pub max_orb: f64,
279    /// Whether it is a major (Ptolemaic) aspect.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub is_major: Option<bool>,
282    /// Always `true`, as in the reference implementation.
283    pub is_applying: bool,
284}
285
286/// A cross-aspect between an overlay body (transit, partner) and a base body (natal).
287#[derive(Debug, Clone, PartialEq, Serialize)]
288pub struct CrossAspect {
289    /// Overlay point (transit, partner).
290    pub body1: String,
291    /// Always "overlay".
292    pub body1_source: &'static str,
293    /// Base point (natal).
294    pub body2: String,
295    /// Always "base".
296    pub body2_source: &'static str,
297    /// Aspect name, e.g. "Trine".
298    pub aspect_type: &'static str,
299    /// Aspect glyph, e.g. "△".
300    pub symbol: &'static str,
301    /// Exact angle of the aspect, in degrees.
302    pub angle: f64,
303    /// Distance from exactness, in degrees (rounded to 0.01).
304    pub orb: f64,
305    /// Largest orb allowed for this pair and aspect, in degrees.
306    pub max_orb: f64,
307    /// Whether it is a major (Ptolemaic) aspect.
308    pub is_major: bool,
309    /// Always `true`, as in the reference implementation.
310    pub is_applying: bool,
311}
312
313/// A named point on the ecliptic.
314#[derive(Debug, Clone, Copy, PartialEq)]
315pub struct Point<'a> {
316    pub name: &'a str,
317    pub longitude: f64,
318}
319
320fn separation(a: f64, b: f64) -> f64 {
321    let diff = pyfloat::rem((a - b).abs(), 360.0);
322    if diff > 180.0 {
323        360.0 - diff
324    } else {
325        diff
326    }
327}
328
329fn is_node_pair(a: &str, b: &str) -> bool {
330    (a == "North Node" && b == "South Node") || (a == "South Node" && b == "North Node")
331}
332
333/// The first aspect rule within orb for a separation, with its orb and max orb.
334fn find_aspect(
335    orbs: &OrbSettings,
336    a: &str,
337    b: &str,
338    diff: f64,
339) -> Result<Option<(&'static AspectRule, f64, f64)>, EngineError> {
340    for rule in &ASPECT_RULES {
341        let max_orb = orbs.max_orb(a, b, rule.name)?;
342        let orb = (diff - rule.angle).abs();
343        if orb <= max_orb {
344            return Ok(Some((rule, orb, max_orb)));
345        }
346    }
347    Ok(None)
348}
349
350/// Aspects between every pair of chart points, skipping the nodal axis.
351pub fn natal_aspects(points: &[Point], orbs: &OrbSettings) -> Result<Vec<Aspect>, EngineError> {
352    let mut out = Vec::new();
353    for (i, p1) in points.iter().enumerate() {
354        for p2 in &points[i + 1..] {
355            if is_node_pair(p1.name, p2.name) {
356                continue;
357            }
358            let diff = separation(p1.longitude, p2.longitude);
359            if let Some((rule, orb, max_orb)) = find_aspect(orbs, p1.name, p2.name, diff)? {
360                out.push(Aspect {
361                    body1: p1.name.to_string(),
362                    body2: p2.name.to_string(),
363                    aspect_type: rule.name,
364                    symbol: rule.symbol,
365                    angle: rule.angle,
366                    orb: pyfloat::round(orb, 2),
367                    max_orb: pyfloat::round(max_orb, 2),
368                    is_major: Some(rule.is_major),
369                    is_applying: true,
370                });
371            }
372        }
373    }
374    Ok(out)
375}
376
377/// `calculate_cross_aspects`: every overlay body against every base body.
378pub fn cross_aspects(
379    base: &[Point],
380    overlay: &[Point],
381    custom_orbs: Option<&Value>,
382) -> Result<Vec<CrossAspect>, EngineError> {
383    let orbs = OrbSettings::merge(custom_orbs)?;
384    let mut out = Vec::new();
385    for o in overlay {
386        for b in base {
387            if is_node_pair(o.name, b.name) {
388                continue;
389            }
390            let diff = separation(o.longitude, b.longitude);
391            if let Some((rule, orb, max_orb)) = find_aspect(&orbs, o.name, b.name, diff)? {
392                out.push(CrossAspect {
393                    body1: o.name.to_string(),
394                    body1_source: "overlay",
395                    body2: b.name.to_string(),
396                    body2_source: "base",
397                    aspect_type: rule.name,
398                    symbol: rule.symbol,
399                    angle: rule.angle,
400                    orb: pyfloat::round(orb, 2),
401                    max_orb: pyfloat::round(max_orb, 2),
402                    is_major: rule.is_major,
403                    is_applying: true,
404                });
405            }
406        }
407    }
408    Ok(out)
409}
410
411/// Chart points (`name`, `ecliptic_longitude`) from a stored planets list.
412pub fn points_from_json(planets: &Value) -> Result<Vec<Point<'_>>, EngineError> {
413    let list = planets
414        .as_array()
415        .ok_or_else(|| EngineError::InvalidInput("planets must be a list".into()))?;
416    list.iter()
417        .map(|p| {
418            let name = p
419                .get("name")
420                .and_then(Value::as_str)
421                .ok_or_else(|| EngineError::InvalidInput("every planet needs a name".into()))?;
422            let longitude = p.get("ecliptic_longitude").map(py_float).ok_or_else(|| {
423                EngineError::InvalidInput(format!("{name} has no ecliptic_longitude"))
424            })??;
425            Ok(Point { name, longitude })
426        })
427        .collect()
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn hybrid_orb_is_capped_by_aspect() {
436        let orbs = OrbSettings::default();
437        // Sun 15 + Moon 12 → moiety 13.5, capped by Conjunction 8.
438        assert_eq!(orbs.max_orb("Sun", "Moon", "Conjunction").unwrap(), 8.0);
439        // Uranus 5 + Neptune 5 → 5, under Conjunction's 8.
440        assert_eq!(
441            orbs.max_orb("Uranus", "Neptune", "Conjunction").unwrap(),
442            5.0
443        );
444        assert_eq!(orbs.max_orb("Sun", "Moon", "Semi-Sextile").unwrap(), 2.5);
445    }
446
447    #[test]
448    fn custom_orbs_merge_key_by_key() {
449        let custom = json!({"method": "fixed_aspect", "aspect_orbs": {"Trine": "6"}});
450        let orbs = OrbSettings::merge(Some(&custom)).unwrap();
451        assert_eq!(orbs.max_orb("Sun", "Moon", "Trine").unwrap(), 6.0);
452        assert_eq!(orbs.max_orb("Sun", "Moon", "Square").unwrap(), 7.0);
453        assert_eq!(orbs.as_map()["aspect_orbs"]["Trine"], json!("6"));
454        let weighted = json!({"method": "moiety_aspect_weighted"});
455        let orbs = OrbSettings::merge(Some(&weighted)).unwrap();
456        // moiety 13.5 × (6 / 8)
457        assert_eq!(orbs.max_orb("Sun", "Moon", "Sextile").unwrap(), 10.12); // 10.125, ties to even
458    }
459}