use vedaksha_math::angle;
#[derive(Debug, Clone)]
pub struct TransitEvent {
pub transiting_body: String,
pub natal_body: String,
pub aspect_type: String,
pub exact_jd: f64,
pub applying: bool,
pub exact_orb: f64,
}
#[derive(Debug, Clone)]
pub struct TransitSearchConfig {
pub natal_positions: Vec<(String, f64)>,
pub start_jd: f64,
pub end_jd: f64,
pub transiting_bodies: Vec<(String, usize)>,
pub aspect_types: Vec<(String, f64)>,
pub max_orb: f64,
pub step_size: f64,
}
pub fn search_transits(
config: &TransitSearchConfig,
get_longitude: &dyn Fn(usize, f64) -> Option<f64>,
) -> Vec<TransitEvent> {
let mut events = Vec::new();
for (body_name, body_idx) in &config.transiting_bodies {
let coarse = coarse_scan(config, *body_idx, get_longitude);
for (natal_name, natal_lon) in &config.natal_positions {
for (aspect_name, aspect_angle) in &config.aspect_types {
let mut search_angles = vec![*aspect_angle];
if *aspect_angle > 0.0 && *aspect_angle < 180.0 {
search_angles.push(360.0 - *aspect_angle);
}
for &search_angle in &search_angles {
scan_for_crossings(
config,
&coarse,
*body_idx,
body_name,
natal_name,
*natal_lon,
aspect_name,
*aspect_angle,
search_angle,
get_longitude,
&mut events,
);
}
}
}
}
events.sort_by(|a, b| {
a.exact_jd
.partial_cmp(&b.exact_jd)
.unwrap_or(core::cmp::Ordering::Equal)
});
events
}
fn coarse_scan(
config: &TransitSearchConfig,
body_idx: usize,
get_longitude: &dyn Fn(usize, f64) -> Option<f64>,
) -> Vec<(f64, Option<f64>)> {
let step = config.step_size;
let mut grid = Vec::new();
let mut t = config.start_jd;
grid.push((t, get_longitude(body_idx, t)));
while t < config.end_jd {
let next_t = (t + step).min(config.end_jd);
grid.push((next_t, get_longitude(body_idx, next_t)));
t = next_t;
}
grid
}
#[allow(clippy::too_many_arguments)]
fn scan_for_crossings(
config: &TransitSearchConfig,
coarse: &[(f64, Option<f64>)],
body_idx: usize,
body_name: &str,
natal_name: &str,
natal_lon: f64,
aspect_name: &str,
aspect_angle: f64,
search_angle: f64,
get_longitude: &dyn Fn(usize, f64) -> Option<f64>,
events: &mut Vec<TransitEvent>,
) {
for window in coarse.windows(2) {
let (t, prev_lon) = window[0];
let (next_t, curr_lon) = window[1];
if let (Some(prev), Some(curr)) = (prev_lon, curr_lon) {
let prev_f = aspect_function(prev, natal_lon, search_angle);
let curr_f = aspect_function(curr, natal_lon, search_angle);
let sign_change = (prev_f * curr_f < 0.0) || (prev_f != 0.0 && curr_f == 0.0);
if sign_change && prev_f.abs() < config.max_orb + 5.0 {
if let Some(exact_jd) = bisect_transit(
t,
next_t,
natal_lon,
search_angle,
body_idx,
get_longitude,
50,
) {
if let Some(exact_lon) = get_longitude(body_idx, exact_jd) {
let orb =
(angle::angular_separation(exact_lon, natal_lon) - aspect_angle).abs();
if orb <= config.max_orb {
events.push(TransitEvent {
transiting_body: body_name.to_owned(),
natal_body: natal_name.to_owned(),
aspect_type: aspect_name.to_owned(),
exact_jd,
applying: curr_f.abs() < prev_f.abs(),
exact_orb: orb,
});
}
}
}
}
}
}
}
pub fn solar_return(
natal_sun_longitude: f64,
search_start_jd: f64,
get_sun_longitude: &dyn Fn(f64) -> Option<f64>,
) -> Option<f64> {
find_return(
natal_sun_longitude,
search_start_jd - 1.0,
search_start_jd + 1.0,
get_sun_longitude,
60,
)
}
pub fn lunar_return(
natal_moon_longitude: f64,
search_start_jd: f64,
get_moon_longitude: &dyn Fn(f64) -> Option<f64>,
) -> Option<f64> {
let mut t = search_start_jd;
for _ in 0..30 {
let lon = get_moon_longitude(t)?;
let next_lon = get_moon_longitude(t + 1.0)?;
if longitude_crosses(lon, next_lon, natal_moon_longitude) {
return find_return(natal_moon_longitude, t, t + 1.0, get_moon_longitude, 60);
}
t += 1.0;
}
None
}
fn aspect_function(transit_lon: f64, natal_lon: f64, target_angle: f64) -> f64 {
let diff = angle::normalize_degrees(transit_lon - natal_lon) - target_angle;
angle::normalize_degrees_signed(diff)
}
fn bisect_transit(
mut t_low: f64,
mut t_high: f64,
natal_lon: f64,
target_angle: f64,
body_idx: usize,
get_longitude: &dyn Fn(usize, f64) -> Option<f64>,
max_iter: u32,
) -> Option<f64> {
let threshold = 1e-8;
let mut lon_low: Option<f64> = None;
for _ in 0..max_iter {
let t_mid = f64::midpoint(t_low, t_high);
if (t_high - t_low) < threshold {
return Some(t_mid);
}
let low = if let Some(l) = lon_low {
l
} else {
let l = get_longitude(body_idx, t_low)?;
lon_low = Some(l);
l
};
let lon_mid = get_longitude(body_idx, t_mid)?;
let f_low = aspect_function(low, natal_lon, target_angle);
let f_mid = aspect_function(lon_mid, natal_lon, target_angle);
if f_low * f_mid < 0.0 {
t_high = t_mid;
} else {
t_low = t_mid;
lon_low = Some(lon_mid);
}
}
Some(f64::midpoint(t_low, t_high))
}
fn longitude_crosses(lon1: f64, lon2: f64, target: f64) -> bool {
let d1 = angle::normalize_degrees(target - lon1);
let d2 = angle::normalize_degrees(target - lon2);
(d1 < 180.0) != (d2 < 180.0)
}
fn find_return(
target_lon: f64,
t_start: f64,
t_end: f64,
get_longitude: &dyn Fn(f64) -> Option<f64>,
max_iter: u32,
) -> Option<f64> {
let mut low = t_start;
let mut high = t_end;
let threshold = 1e-10;
for _ in 0..max_iter {
let mid = f64::midpoint(low, high);
if (high - low) < threshold {
return Some(mid);
}
let lon_low = get_longitude(low)?;
let lon_mid = get_longitude(mid)?;
let d_low = angle::normalize_degrees(target_lon - lon_low);
let d_mid = angle::normalize_degrees(target_lon - lon_mid);
if (d_low < 180.0) == (d_mid < 180.0) {
low = mid;
} else {
high = mid;
}
}
Some(f64::midpoint(low, high))
}
#[cfg(test)]
mod tests {
use super::*;
const BASE_JD: f64 = 2_451_545.0;
fn linear_longitude(body_idx: usize, jd: f64) -> Option<f64> {
let speed = match body_idx {
0 => 1.0,
1 => 13.0,
_ => 0.5,
};
Some(angle::normalize_degrees(speed * (jd - BASE_JD)))
}
fn sun_longitude(jd: f64) -> Option<f64> {
Some(angle::normalize_degrees(1.0 * (jd - BASE_JD)))
}
fn moon_longitude(jd: f64) -> Option<f64> {
Some(angle::normalize_degrees(13.0 * (jd - BASE_JD)))
}
#[test]
fn find_conjunction_with_natal_point() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalSun".into(), 30.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 60.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Conjunction".into(), 0.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty(), "Should find at least one conjunction");
let first = &events[0];
assert!(
(first.exact_jd - (BASE_JD + 30.0)).abs() < 0.5,
"Expected near day 30, got {}",
first.exact_jd
);
}
#[test]
fn find_opposition() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 0.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 200.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Opposition".into(), 180.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty(), "Should find opposition");
let first = &events[0];
assert!(
(first.exact_jd - (BASE_JD + 180.0)).abs() < 0.5,
"Expected near day 180, got {}",
first.exact_jd
);
}
#[test]
fn find_trine() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 0.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 150.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Trine".into(), 120.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty(), "Should find trine");
let first = &events[0];
assert!(
(first.exact_jd - (BASE_JD + 120.0)).abs() < 0.5,
"Expected near day 120, got {}",
first.exact_jd
);
}
#[test]
fn find_square() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 0.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 120.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Square".into(), 90.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty(), "Should find square");
let first = &events[0];
assert!(
(first.exact_jd - (BASE_JD + 90.0)).abs() < 0.5,
"Expected near day 90, got {}",
first.exact_jd
);
}
#[test]
fn find_sextile() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 0.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 80.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Sextile".into(), 60.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty(), "Should find sextile");
let first = &events[0];
assert!(
(first.exact_jd - (BASE_JD + 60.0)).abs() < 0.5,
"Expected near day 60, got {}",
first.exact_jd
);
}
#[test]
fn no_events_outside_range() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 90.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 10.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Conjunction".into(), 0.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(events.is_empty(), "Should find no events in short range");
}
#[test]
fn events_sorted_by_jd() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 0.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 200.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![
("Sextile".into(), 60.0),
("Square".into(), 90.0),
("Trine".into(), 120.0),
("Opposition".into(), 180.0),
],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(events.len() >= 4, "Should find multiple aspects");
for w in events.windows(2) {
assert!(
w[0].exact_jd <= w[1].exact_jd,
"Events not sorted: {} > {}",
w[0].exact_jd,
w[1].exact_jd
);
}
}
#[test]
fn orb_at_exact_moment_is_small() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 30.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 60.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Conjunction".into(), 0.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty());
assert!(
events[0].exact_orb < 0.001,
"Orb should be near zero, got {}",
events[0].exact_orb
);
}
#[test]
fn multiple_bodies_and_natal_points() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalSun".into(), 30.0), ("NatalMoon".into(), 60.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 60.0,
transiting_bodies: vec![("Sun".into(), 0), ("Moon".into(), 1)],
aspect_types: vec![("Conjunction".into(), 0.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &linear_longitude);
assert!(!events.is_empty(), "Should find events for multiple bodies");
}
#[test]
fn callback_returning_none_is_handled() {
let config = TransitSearchConfig {
natal_positions: vec![("NatalPoint".into(), 30.0)],
start_jd: BASE_JD,
end_jd: BASE_JD + 60.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![("Conjunction".into(), 0.0)],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &|_, _| None);
assert!(events.is_empty(), "Should handle None gracefully");
}
#[test]
fn solar_return_linear() {
let natal_lon = 280.0;
let start_jd = BASE_JD + 280.0;
let result = solar_return(natal_lon, start_jd, &sun_longitude);
assert!(result.is_some(), "Should find solar return");
let return_jd = result.unwrap();
assert!(
(return_jd - (BASE_JD + 280.0)).abs() < 0.01,
"Expected near day 280, got offset {}",
return_jd - BASE_JD
);
}
#[test]
fn lunar_return_within_cycle() {
let natal_lon = 90.0;
let result = lunar_return(natal_lon, BASE_JD, &moon_longitude);
assert!(result.is_some(), "Should find lunar return");
let return_jd = result.unwrap();
let expected = BASE_JD + 90.0 / 13.0;
assert!(
(return_jd - expected).abs() < 0.1,
"Expected near day {:.2}, got offset {:.2}",
90.0 / 13.0,
return_jd - BASE_JD
);
}
#[test]
fn lunar_return_none_callback() {
let result = lunar_return(90.0, BASE_JD, &|_| None);
assert!(result.is_none(), "Should return None on callback failure");
}
#[test]
fn longitude_crosses_basic() {
assert!(longitude_crosses(350.0, 10.0, 0.0));
assert!(!longitude_crosses(10.0, 20.0, 0.0));
}
#[test]
fn coarse_scan_cached_across_natal_and_aspect() {
use core::cell::Cell;
let calls = Cell::new(0_usize);
let get_lon = |_idx: usize, jd: f64| -> Option<f64> {
calls.set(calls.get() + 1);
Some((jd - BASE_JD).rem_euclid(360.0)) };
let config = TransitSearchConfig {
natal_positions: vec![
("N1".into(), 10.0),
("N2".into(), 40.0),
("N3".into(), 70.0),
("N4".into(), 130.0),
("N5".into(), 200.0),
],
start_jd: BASE_JD,
end_jd: BASE_JD + 365.0,
transiting_bodies: vec![("Sun".into(), 0)],
aspect_types: vec![
("Conjunction".into(), 0.0),
("Sextile".into(), 60.0),
("Square".into(), 90.0),
("Trine".into(), 120.0),
],
max_orb: 1.0,
step_size: 1.0,
};
let events = search_transits(&config, &get_lon);
assert!(!events.is_empty(), "should detect crossings");
let total = calls.get();
assert!(
total < 3000,
"search_transits made {total} longitude calls; coarse scan should be \
cached across natal/aspect (uncached ≈ 11000)"
);
}
}