pub mod patterns;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum AspectType {
Conjunction,
Sextile,
Square,
Trine,
Opposition,
SemiSextile,
Quincunx,
SemiSquare,
Sesquiquadrate,
Quintile,
BiQuintile,
}
impl AspectType {
#[must_use]
pub const 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,
Self::SemiSextile => 30.0,
Self::Quincunx => 150.0,
Self::SemiSquare => 45.0,
Self::Sesquiquadrate => 135.0,
Self::Quintile => 72.0,
Self::BiQuintile => 144.0,
}
}
#[must_use]
pub const fn is_major(&self) -> bool {
matches!(
self,
Self::Conjunction | Self::Sextile | Self::Square | Self::Trine | Self::Opposition
)
}
#[must_use]
pub const fn default_orb(&self) -> f64 {
match self {
Self::Sextile => 6.0,
Self::Square => 7.0,
Self::Conjunction | Self::Trine | Self::Opposition => 8.0,
Self::SemiSextile
| Self::Quincunx
| Self::SemiSquare
| Self::Sesquiquadrate
| Self::Quintile
| Self::BiQuintile => 2.0,
}
}
pub const ALL: &'static [Self] = &[
Self::Conjunction,
Self::Sextile,
Self::Square,
Self::Trine,
Self::Opposition,
Self::SemiSextile,
Self::Quincunx,
Self::SemiSquare,
Self::Sesquiquadrate,
Self::Quintile,
Self::BiQuintile,
];
pub const MAJOR: &'static [Self] = &[
Self::Conjunction,
Self::Sextile,
Self::Square,
Self::Trine,
Self::Opposition,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AspectMotion {
Applying,
Separating,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Aspect {
pub body1_index: usize,
pub body2_index: usize,
pub aspect_type: AspectType,
pub orb: f64,
pub motion: AspectMotion,
pub strength: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct BodyPosition {
pub longitude: f64,
pub speed: f64,
}
#[must_use]
pub fn find_aspects(
positions: &[BodyPosition],
aspect_types: &[AspectType],
orb_factor: f64,
) -> Vec<Aspect> {
let mut aspects = Vec::new();
for i in 0..positions.len() {
for j in (i + 1)..positions.len() {
let separation = vedaksha_math::angle::angular_separation(
positions[i].longitude,
positions[j].longitude,
);
for &aspect_type in aspect_types {
let target_angle = aspect_type.angle();
let max_orb = aspect_type.default_orb() * orb_factor;
let orb = (separation - target_angle).abs();
if orb <= max_orb {
let relative_speed = positions[i].speed - positions[j].speed;
let motion = determine_motion(
positions[i].longitude,
positions[j].longitude,
relative_speed,
target_angle,
);
let strength = 1.0 - orb / max_orb;
aspects.push(Aspect {
body1_index: i,
body2_index: j,
aspect_type,
orb,
motion,
strength,
});
}
}
}
}
aspects
}
fn determine_motion(lon1: f64, lon2: f64, relative_speed: f64, target_angle: f64) -> AspectMotion {
let diff = vedaksha_math::angle::normalize_degrees(lon1 - lon2);
let future_diff =
vedaksha_math::angle::normalize_degrees((lon1 + relative_speed * 0.01) - lon2);
let current_orb = orb_from_diff(diff, target_angle);
let future_orb = orb_from_diff(future_diff, target_angle);
if future_orb < current_orb {
AspectMotion::Applying
} else {
AspectMotion::Separating
}
}
fn orb_from_diff(diff: f64, target_angle: f64) -> f64 {
let candidates = [
(diff - target_angle).abs(),
(diff - target_angle + 360.0).abs(),
(diff - target_angle - 360.0).abs(),
];
candidates.iter().copied().fold(f64::INFINITY, f64::min)
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
fn pos(longitude: f64, speed: f64) -> BodyPosition {
BodyPosition { longitude, speed }
}
#[test]
fn conjunction_angle_is_zero() {
assert!((AspectType::Conjunction.angle() - 0.0).abs() < EPS);
}
#[test]
fn opposition_angle_is_180() {
assert!((AspectType::Opposition.angle() - 180.0).abs() < EPS);
}
#[test]
fn is_major_for_trine() {
assert!(AspectType::Trine.is_major());
}
#[test]
fn is_not_major_for_quincunx() {
assert!(!AspectType::Quincunx.is_major());
}
#[test]
fn detects_sextile_at_60_degrees() {
let positions = [pos(0.0, 1.0), pos(60.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
assert!(
aspects.iter().any(|a| a.aspect_type == AspectType::Sextile),
"Expected a sextile"
);
}
#[test]
fn detects_square_at_90_degrees() {
let positions = [pos(0.0, 1.0), pos(90.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
assert!(
aspects.iter().any(|a| a.aspect_type == AspectType::Square),
"Expected a square"
);
}
#[test]
fn detects_trine_at_120_degrees() {
let positions = [pos(0.0, 1.0), pos(120.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
assert!(
aspects.iter().any(|a| a.aspect_type == AspectType::Trine),
"Expected a trine"
);
}
#[test]
fn detects_conjunction_wrap_around() {
let positions = [pos(359.0, 1.0), pos(1.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
assert!(
aspects
.iter()
.any(|a| a.aspect_type == AspectType::Conjunction),
"Expected a conjunction across 0°"
);
}
#[test]
fn orb_boundary_just_within_trine() {
let positions = [pos(0.0, 1.0), pos(127.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
assert!(
aspects.iter().any(|a| a.aspect_type == AspectType::Trine),
"Expected trine within orb"
);
}
#[test]
fn no_trine_outside_orb() {
let positions = [pos(0.0, 1.0), pos(129.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
assert!(
!aspects.iter().any(|a| a.aspect_type == AspectType::Trine),
"Should not find trine outside orb"
);
}
#[test]
fn applying_when_faster_body_approaches() {
let positions = [pos(118.0, 2.0), pos(0.0, 0.0)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
let trine = aspects.iter().find(|a| a.aspect_type == AspectType::Trine);
assert!(trine.is_some(), "Expected a trine");
assert_eq!(
trine.unwrap().motion,
AspectMotion::Applying,
"Should be applying"
);
}
#[test]
fn separating_when_bodies_move_apart() {
let positions = [pos(122.0, 2.0), pos(0.0, 0.0)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
let trine = aspects.iter().find(|a| a.aspect_type == AspectType::Trine);
assert!(trine.is_some(), "Expected a trine");
assert_eq!(
trine.unwrap().motion,
AspectMotion::Separating,
"Should be separating"
);
}
#[test]
fn exact_aspect_has_strength_one() {
let positions = [pos(0.0, 1.0), pos(120.0, 0.0)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 1.0);
let trine = aspects
.iter()
.find(|a| a.aspect_type == AspectType::Trine)
.expect("Expected a trine");
assert!(
(trine.strength - 1.0).abs() < EPS,
"Exact aspect should have strength 1.0, got {}",
trine.strength
);
}
#[test]
fn orb_factor_restricts_detection() {
let positions = [pos(0.0, 1.0), pos(127.0, 0.5)];
let aspects = find_aspects(&positions, AspectType::MAJOR, 0.1);
assert!(
!aspects.iter().any(|a| a.aspect_type == AspectType::Trine),
"Should not find trine with very tight orb factor"
);
}
}