use crate::coords::normalize_degrees;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AspectKind {
Conjunction,
Sextile,
Square,
Trine,
Opposition,
}
impl AspectKind {
pub fn angle(self) -> f64 {
match self {
Self::Conjunction => 0.0,
Self::Sextile => 60.0,
Self::Square => 90.0,
Self::Trine => 120.0,
Self::Opposition => 180.0,
}
}
pub fn default_orb(self) -> f64 {
match self {
Self::Conjunction => 10.0,
Self::Opposition => 10.0,
Self::Trine => 8.0,
Self::Square => 8.0,
Self::Sextile => 6.0,
}
}
pub const ALL: [AspectKind; 5] = [
Self::Conjunction,
Self::Sextile,
Self::Square,
Self::Trine,
Self::Opposition,
];
}
impl fmt::Display for AspectKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Conjunction => write!(f, "Conjunction"),
Self::Sextile => write!(f, "Sextile"),
Self::Square => write!(f, "Square"),
Self::Trine => write!(f, "Trine"),
Self::Opposition => write!(f, "Opposition"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Aspect {
pub kind: AspectKind,
pub separation: f64,
pub orb: f64,
pub strength: f64,
}
pub fn angular_separation(lon1: f64, lon2: f64) -> f64 {
let diff = normalize_degrees(lon1 - lon2);
if diff > 180.0 { 360.0 - diff } else { diff }
}
pub fn find_aspect(lon1: f64, lon2: f64) -> Option<Aspect> {
find_aspect_with_orbs(lon1, lon2, &AspectKind::ALL.map(|k| (k, k.default_orb())))
}
pub fn find_aspect_with_orbs(lon1: f64, lon2: f64, orbs: &[(AspectKind, f64)]) -> Option<Aspect> {
let sep = angular_separation(lon1, lon2);
let mut best: Option<Aspect> = None;
for &(kind, max_orb) in orbs {
let orb = (sep - kind.angle()).abs();
if orb <= max_orb {
let strength = 1.0 - orb / max_orb;
let is_better = best.as_ref().is_none_or(|b| strength > b.strength);
if is_better {
best = Some(Aspect {
kind,
separation: sep,
orb,
strength,
});
}
}
}
best
}
pub fn find_all_aspects(lon1: f64, lon2: f64) -> Vec<Aspect> {
find_all_aspects_with_orbs(lon1, lon2, &AspectKind::ALL.map(|k| (k, k.default_orb())))
}
pub fn find_all_aspects_with_orbs(lon1: f64, lon2: f64, orbs: &[(AspectKind, f64)]) -> Vec<Aspect> {
let sep = angular_separation(lon1, lon2);
let mut aspects: Vec<Aspect> = orbs
.iter()
.filter_map(|&(kind, max_orb)| {
let orb = (sep - kind.angle()).abs();
if orb <= max_orb {
Some(Aspect {
kind,
separation: sep,
orb,
strength: 1.0 - orb / max_orb,
})
} else {
None
}
})
.collect();
aspects.sort_by(|a, b| b.strength.total_cmp(&a.strength));
aspects
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn angular_separation_basic() {
assert!((angular_separation(0.0, 0.0)).abs() < 1e-10);
assert!((angular_separation(0.0, 180.0) - 180.0).abs() < 1e-10);
assert!((angular_separation(10.0, 350.0) - 20.0).abs() < 1e-10);
assert!((angular_separation(350.0, 10.0) - 20.0).abs() < 1e-10);
}
#[test]
fn find_conjunction() {
let asp = find_aspect(100.0, 103.0).unwrap();
assert_eq!(asp.kind, AspectKind::Conjunction);
assert!((asp.orb - 3.0).abs() < 1e-10);
}
#[test]
fn find_trine() {
let asp = find_aspect(0.0, 120.5).unwrap();
assert_eq!(asp.kind, AspectKind::Trine);
assert!((asp.orb - 0.5).abs() < 1e-10);
}
#[test]
fn find_opposition() {
let asp = find_aspect(10.0, 191.0).unwrap();
assert_eq!(asp.kind, AspectKind::Opposition);
assert!((asp.orb - 1.0).abs() < 1e-10);
}
#[test]
fn find_square() {
let asp = find_aspect(0.0, 88.0).unwrap();
assert_eq!(asp.kind, AspectKind::Square);
assert!((asp.orb - 2.0).abs() < 1e-10);
}
#[test]
fn find_sextile() {
let asp = find_aspect(0.0, 62.0).unwrap();
assert_eq!(asp.kind, AspectKind::Sextile);
assert!((asp.orb - 2.0).abs() < 1e-10);
}
#[test]
fn no_aspect_found() {
let asp = find_aspect(0.0, 45.0);
assert!(asp.is_none());
}
#[test]
fn exact_aspect_strength_is_one() {
let asp = find_aspect(0.0, 120.0).unwrap();
assert_eq!(asp.kind, AspectKind::Trine);
assert!((asp.strength - 1.0).abs() < 1e-10);
}
#[test]
fn aspect_strength_at_orb_limit() {
let asp = find_aspect(0.0, 128.0).unwrap();
assert_eq!(asp.kind, AspectKind::Trine);
assert!(asp.strength.abs() < 1e-10); }
#[test]
fn find_all_aspects_multiple() {
let aspects = find_all_aspects(0.0, 0.5);
assert!(!aspects.is_empty());
assert_eq!(aspects[0].kind, AspectKind::Conjunction);
}
#[test]
fn aspect_kind_display() {
assert_eq!(AspectKind::Conjunction.to_string(), "Conjunction");
assert_eq!(AspectKind::Opposition.to_string(), "Opposition");
}
#[test]
fn aspect_kind_serde() {
let kind = AspectKind::Trine;
let json = serde_json::to_string(&kind).unwrap();
let restored: AspectKind = serde_json::from_str(&json).unwrap();
assert_eq!(restored, kind);
}
#[test]
fn custom_orbs() {
let tight_orbs = [(AspectKind::Conjunction, 1.0)];
assert!(find_aspect_with_orbs(0.0, 5.0, &tight_orbs).is_none());
assert!(find_aspect_with_orbs(0.0, 0.5, &tight_orbs).is_some());
}
}