use std::cmp::Ordering;
use crate::geo::Ned;
use crate::neighbor::{NearestSet, NeighborState};
pub const SEPARATION_RADIUS_M: f64 = 8.0;
pub const SEPARATION_HARD_M: f64 = 4.0;
pub const SEPARATION_GAIN: f64 = 1.5;
pub const SEPARATION_NEIGHBORS: usize = 7;
pub const HARD_CLIMB_RATE_MPS: f64 = 1.0;
pub const HARD_CLIMB_STEP_M: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeparationTuning {
pub radius_m: f64,
pub hard_m: f64,
pub gain: f64,
pub neighbors: usize,
}
impl Default for SeparationTuning {
fn default() -> Self {
Self {
radius_m: SEPARATION_RADIUS_M,
hard_m: SEPARATION_HARD_M,
gain: SEPARATION_GAIN,
neighbors: SEPARATION_NEIGHBORS,
}
}
}
impl SeparationTuning {
pub fn sanitised(mut self) -> Self {
if !self.radius_m.is_finite() || self.radius_m <= 0.0 {
self.radius_m = SEPARATION_RADIUS_M;
}
if !self.hard_m.is_finite() || self.hard_m <= 0.0 {
self.hard_m = SEPARATION_HARD_M;
}
if !self.gain.is_finite() || self.gain < 0.0 {
self.gain = SEPARATION_GAIN;
}
if self.hard_m >= self.radius_m {
self.radius_m = self.hard_m * 2.0;
}
if self.neighbors == 0 {
self.neighbors = SEPARATION_NEIGHBORS;
}
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HardBreach {
pub slot: u8,
pub distance_m: f64,
pub relative_alt_m: f64,
pub sender_order: Ordering,
}
pub fn repulsion(neighbors: &[NeighborState], t: &SeparationTuning) -> Ned {
let set = NearestSet::build(neighbors, t.radius_m, t.neighbors);
repulsion_over(neighbors, &set, t)
}
pub fn repulsion_over(neighbors: &[NeighborState], set: &NearestSet, t: &SeparationTuning) -> Ned {
let inv_r = 1.0 / t.radius_m;
let mut acc = Ned::ZERO;
for (i, d) in set.iter() {
if d >= t.radius_m || d <= f64::EPSILON {
continue;
}
let magnitude = t.gain * (1.0 / d - inv_r);
acc = acc + neighbors[i].pos.scale(-magnitude / d);
}
acc
}
pub fn hard_breach(neighbors: &[NeighborState], t: &SeparationTuning) -> Option<HardBreach> {
let set = NearestSet::build(neighbors, t.hard_m, 1);
set.closest().map(|(i, d)| HardBreach {
slot: neighbors[i].slot,
distance_m: d,
relative_alt_m: -neighbors[i].pos.d,
sender_order: neighbors[i].sender_order,
})
}
pub fn climbs(own_slot: u8, offender_slot: u8, sender_order: Ordering) -> bool {
match own_slot.cmp(&offender_slot) {
Ordering::Less => true,
Ordering::Greater => false,
Ordering::Equal => sender_order == Ordering::Less,
}
}
pub fn climb_offset_m(own_slot: u8, offender_slot: u8, sender_order: Ordering) -> f64 {
if !climbs(own_slot, offender_slot, sender_order) {
return 0.0;
}
let gap = offender_slot.saturating_sub(own_slot).max(1);
HARD_CLIMB_STEP_M * gap as f64
}
pub fn hard_climb_target_m(
own_alt_m: f64,
own_slot: u8,
offender_slot: u8,
sender_order: Ordering,
offender_relative_alt_m: f64,
) -> f64 {
if !climbs(own_slot, offender_slot, sender_order) || offender_relative_alt_m > 0.0 {
return own_alt_m;
}
own_alt_m + offender_relative_alt_m + climb_offset_m(own_slot, offender_slot, sender_order)
}
pub fn hard_override(alt_m: f64, target_alt_m: f64) -> Ned {
if alt_m >= target_alt_m {
return Ned::ZERO;
}
let remaining = target_alt_m - alt_m;
let rate = HARD_CLIMB_RATE_MPS.min(remaining);
Ned::new(0.0, 0.0, -rate)
}
pub fn min_pairwise_distance(positions: &[Ned]) -> Option<f64> {
let mut best: Option<f64> = None;
for (i, a) in positions.iter().enumerate() {
for b in &positions[i + 1..] {
let d = (*a - *b).norm();
if best.is_none_or(|m| d < m) {
best = Some(d);
}
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
fn nbr(slot: u8, n: f64, e: f64, d: f64) -> NeighborState {
NeighborState::new(slot, Ned::new(n, e, d), Ned::ZERO, 0)
}
#[test]
fn repulsion_points_away_and_grows_as_range_closes() {
let t = SeparationTuning::default();
let mut last = 0.0;
for d in [7.5, 6.0, 5.0, 4.5, 3.0, 1.0] {
let f = repulsion(&[nbr(2, d, 0.0, 0.0)], &t);
assert!(f.n < 0.0, "at {d} m the push is {f:?}, not southward");
assert_eq!(f.e, 0.0);
assert_eq!(f.d, 0.0);
let m = f.norm();
assert!(
m > last,
"magnitude must grow as range closes: {m} <= {last}"
);
last = m;
}
}
#[test]
fn repulsion_matches_the_plans_closed_form() {
let t = SeparationTuning::default();
let d = 5.0;
let f = repulsion(&[nbr(2, 0.0, d, 0.0)], &t);
let expect = SEPARATION_GAIN * (1.0 / d - 1.0 / SEPARATION_RADIUS_M);
assert!(
(f.norm() - expect).abs() < 1e-12,
"{} vs {expect}",
f.norm()
);
assert!((f.e + expect).abs() < 1e-12);
}
#[test]
fn repulsion_is_zero_at_and_beyond_the_radius() {
let t = SeparationTuning::default();
assert_eq!(
repulsion(&[nbr(2, SEPARATION_RADIUS_M, 0.0, 0.0)], &t),
Ned::ZERO
);
assert_eq!(repulsion(&[nbr(2, 40.0, 0.0, 0.0)], &t), Ned::ZERO);
let just_in = repulsion(&[nbr(2, SEPARATION_RADIUS_M - 1e-6, 0.0, 0.0)], &t);
assert!(just_in.norm() < 1e-6, "{}", just_in.norm());
}
#[test]
fn repulsion_sums_over_neighbours_and_cancels_symmetric_pairs() {
let t = SeparationTuning::default();
let f = repulsion(&[nbr(2, 5.0, 0.0, 0.0), nbr(3, -5.0, 0.0, 0.0)], &t);
assert!(f.norm() < 1e-12, "{f:?}");
let one = repulsion(&[nbr(2, 5.0, 0.0, 0.0)], &t).norm();
let three = repulsion(
&[
nbr(2, 5.0, 0.0, 0.0),
nbr(3, 5.0, 0.001, 0.0),
nbr(4, 5.0, -0.001, 0.0),
],
&t,
)
.norm();
assert!(three > 2.9 * one, "{three} vs {one}");
}
#[test]
fn repulsion_weights_only_k_nearest() {
let t = SeparationTuning {
neighbors: 2,
..Default::default()
};
let ns: Vec<NeighborState> = (1..=5).map(|s| nbr(s, s as f64, 0.0, 0.0)).collect();
let capped = repulsion(&ns, &t);
let two_nearest = repulsion(&ns[..2], &t);
assert!((capped.norm() - two_nearest.norm()).abs() < 1e-12);
}
#[test]
fn a_coincident_neighbour_does_not_produce_a_nan_command() {
let t = SeparationTuning::default();
let f = repulsion(&[nbr(2, 0.0, 0.0, 0.0)], &t);
assert!(f.is_finite(), "{f:?}");
assert_eq!(
f,
Ned::ZERO,
"no direction exists; the hard layer owns this"
);
}
#[test]
fn hard_breach_reports_the_nearest_offender_only_inside_the_hard_radius() {
let t = SeparationTuning::default();
assert_eq!(hard_breach(&[nbr(2, 4.0, 0.0, 0.0)], &t), None);
assert_eq!(hard_breach(&[nbr(2, 4.001, 0.0, 0.0)], &t), None);
let b = hard_breach(&[nbr(7, 3.5, 0.0, 0.0), nbr(9, 2.0, 0.0, 0.0)], &t)
.expect("two inside the hard radius");
assert_eq!(b.slot, 9, "the NEAREST offender, not the first seen");
assert!((b.distance_m - 2.0).abs() < 1e-12);
assert!(hard_breach(&[nbr(2, 0.0, 0.0, 3.0)], &t).is_some());
}
#[test]
fn climb_offset_is_deterministic_and_monotone_in_the_slot_gap() {
let mut last = f64::NEG_INFINITY;
for own in (1..=23u8).rev() {
let o = climb_offset_m(own, 24, Ordering::Equal);
assert!(
o > last,
"own {own} offset {o} did not increase past {last}"
);
assert!((o - 0.5 * (24 - own) as f64).abs() < 1e-12);
last = o;
}
assert_eq!(
climb_offset_m(1, 3, Ordering::Equal),
climb_offset_m(1, 3, Ordering::Equal)
);
assert!(climb_offset_m(1, 2, Ordering::Equal) < climb_offset_m(1, 3, Ordering::Equal));
assert!(climb_offset_m(1, 2, Ordering::Greater) > 0.0);
assert_eq!(climb_offset_m(2, 1, Ordering::Less), 0.0);
}
#[test]
fn a_same_slot_pair_breaks_the_tie_on_the_sender_id_so_exactly_one_climbs() {
let a = [0x10u8; 8];
let b = [0x20u8; 8];
let a_target = hard_climb_target_m(30.0, 5, 5, a.cmp(&b), 0.0);
let b_target = hard_climb_target_m(30.0, 5, 5, b.cmp(&a), 0.0);
let climbers = [a_target, b_target].iter().filter(|t| **t > 30.0).count();
assert_eq!(climbers, 1, "a {a_target}, b {b_target}");
assert!(
(a_target - (30.0 + HARD_CLIMB_STEP_M)).abs() < 1e-12,
"{a_target}"
);
assert_eq!(b_target, 30.0);
assert_eq!(hard_climb_target_m(30.0, 5, 5, Ordering::Equal, 0.0), 30.0);
let t = SeparationTuning::default();
let n = nbr(5, 2.0, 0.0, 0.0).with_sender_order(Ordering::Less);
assert_eq!(hard_breach(&[n], &t).unwrap().sender_order, Ordering::Less);
}
#[test]
fn the_climb_target_is_stationary_so_a_pair_cannot_ratchet() {
let low = hard_climb_target_m(30.0, 1, 4, Ordering::Equal, 0.0);
let high = hard_climb_target_m(30.0, 4, 1, Ordering::Equal, 0.0);
assert!((low - 31.5).abs() < 1e-12, "{low}");
assert_eq!(
high, 30.0,
"the higher slot holds, which is what fixes the datum"
);
let again = hard_climb_target_m(31.5, 1, 4, Ordering::Equal, 30.0 - 31.5);
assert!((again - 31.5).abs() < 1e-12, "{again}");
assert_eq!(
hard_climb_target_m(30.0, 4, 1, Ordering::Equal, 31.5 - 30.0),
30.0
);
let above = hard_climb_target_m(40.0, 1, 4, Ordering::Equal, 30.0 - 40.0);
assert!((above - 31.5).abs() < 1e-12);
assert_eq!(
hard_override(40.0, above),
Ned::ZERO,
"no descent is commanded"
);
}
#[test]
fn hard_override_climbs_then_holds() {
let v = hard_override(100.0, 103.0);
assert_eq!(v.n, 0.0);
assert_eq!(v.e, 0.0);
assert!((v.d + HARD_CLIMB_RATE_MPS).abs() < 1e-12);
let near = hard_override(100.0, 100.4);
assert!((near.d + 0.4).abs() < 1e-12);
assert_eq!(hard_override(103.0, 103.0), Ned::ZERO);
assert_eq!(hard_override(110.0, 103.0), Ned::ZERO);
}
#[test]
fn tuning_sanitises_an_inverted_or_absurd_pair() {
let t = SeparationTuning {
radius_m: 3.0,
hard_m: 6.0,
..Default::default()
}
.sanitised();
assert!(t.hard_m < t.radius_m, "{t:?}");
assert_eq!(
t.hard_m, 6.0,
"the SAFETY radius is the one that is honoured"
);
let t = SeparationTuning {
radius_m: f64::NAN,
hard_m: -1.0,
gain: f64::INFINITY,
neighbors: 0,
}
.sanitised();
assert_eq!(t.radius_m, SEPARATION_RADIUS_M);
assert_eq!(t.hard_m, SEPARATION_HARD_M);
assert_eq!(t.gain, SEPARATION_GAIN);
assert_eq!(t.neighbors, SEPARATION_NEIGHBORS);
}
#[test]
fn min_pairwise_distance_finds_the_closest_pair() {
assert_eq!(min_pairwise_distance(&[]), None);
assert_eq!(min_pairwise_distance(&[Ned::ZERO]), None);
let ps = [
Ned::new(0.0, 0.0, 0.0),
Ned::new(10.0, 0.0, 0.0),
Ned::new(10.0, 3.0, 0.0),
];
assert!((min_pairwise_distance(&ps).unwrap() - 3.0).abs() < 1e-12);
}
}