1use 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
81pub 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
133pub 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
155pub(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#[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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
304pub struct Aspect {
305 pub body1: String,
307 pub body2: String,
309 pub aspect_type: &'static str,
311 pub symbol: &'static str,
313 pub angle: f64,
315 pub orb: f64,
317 pub max_orb: f64,
319 #[serde(skip_serializing_if = "Option::is_none")]
321 pub is_major: Option<bool>,
322 pub is_applying: bool,
324}
325
326#[derive(Debug, Clone, PartialEq, Serialize)]
328pub struct CrossAspect {
329 pub body1: String,
331 pub body1_source: &'static str,
333 pub body2: String,
335 pub body2_source: &'static str,
337 pub aspect_type: &'static str,
339 pub symbol: &'static str,
341 pub angle: f64,
343 pub orb: f64,
345 pub max_orb: f64,
347 pub is_major: bool,
349 pub is_applying: bool,
351}
352
353#[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
364fn 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
381fn is_minor_pair(a: &str, b: &str) -> bool {
384 a != b && is_minor_body(a) && is_minor_body(b)
385}
386
387pub 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
415pub 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
449pub 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 assert_eq!(orbs.max_orb("Sun", "Moon", "Conjunction").unwrap(), 8.0);
478 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 assert_eq!(orbs.max_orb("Sun", "Moon", "Sextile").unwrap(), 10.12); }
498
499 #[test]
500 fn a_minor_body_keeps_to_its_own_orb() {
501 let orbs = OrbSettings::default();
502 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 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 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}