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