1use 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
80pub 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
122pub 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#[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 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
264pub struct Aspect {
265 pub body1: String,
267 pub body2: String,
269 pub aspect_type: &'static str,
271 pub symbol: &'static str,
273 pub angle: f64,
275 pub orb: f64,
277 pub max_orb: f64,
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub is_major: Option<bool>,
282 pub is_applying: bool,
284}
285
286#[derive(Debug, Clone, PartialEq, Serialize)]
288pub struct CrossAspect {
289 pub body1: String,
291 pub body1_source: &'static str,
293 pub body2: String,
295 pub body2_source: &'static str,
297 pub aspect_type: &'static str,
299 pub symbol: &'static str,
301 pub angle: f64,
303 pub orb: f64,
305 pub max_orb: f64,
307 pub is_major: bool,
309 pub is_applying: bool,
311}
312
313#[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
333fn 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
350pub 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
377pub 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
411pub 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 assert_eq!(orbs.max_orb("Sun", "Moon", "Conjunction").unwrap(), 8.0);
439 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 assert_eq!(orbs.max_orb("Sun", "Moon", "Sextile").unwrap(), 10.12); }
459}