use crate::geo::Ned;
use crate::neighbor::{NearestSet, NeighborState};
use crate::separation::{repulsion_over, SeparationTuning};
pub const FLOCK_RADIUS_M: f64 = 30.0;
pub const FLOCK_NEIGHBORS: usize = 7;
pub const FLOCK_COHESION: f64 = 0.4;
pub const FLOCK_ALIGNMENT: f64 = 0.6;
pub const FLOCK_TARGET: f64 = 0.8;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FlockTuning {
pub radius_m: f64,
pub neighbors: usize,
pub cohesion: f64,
pub alignment: f64,
pub target: f64,
pub separation: SeparationTuning,
}
impl Default for FlockTuning {
fn default() -> Self {
Self {
radius_m: FLOCK_RADIUS_M,
neighbors: FLOCK_NEIGHBORS,
cohesion: FLOCK_COHESION,
alignment: FLOCK_ALIGNMENT,
target: FLOCK_TARGET,
separation: SeparationTuning::default(),
}
}
}
impl FlockTuning {
pub fn sanitised(mut self) -> Self {
self.separation = self.separation.sanitised();
if !self.radius_m.is_finite() || self.radius_m <= self.separation.radius_m {
self.radius_m = FLOCK_RADIUS_M.max(self.separation.radius_m * 2.0);
}
if self.neighbors == 0 {
self.neighbors = FLOCK_NEIGHBORS;
}
for g in [&mut self.cohesion, &mut self.alignment, &mut self.target] {
if !g.is_finite() || *g < 0.0 {
*g = 0.0;
}
}
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct FlockTerms {
pub cohesion: Ned,
pub alignment: Ned,
pub separation: Ned,
pub target: Ned,
pub neighbors_used: usize,
}
impl FlockTerms {
pub fn sum(&self) -> Ned {
self.cohesion + self.alignment + self.separation + self.target
}
}
pub fn command(
own_vel: Ned,
target: Option<Ned>,
neighbors: &[NeighborState],
t: &FlockTuning,
cruise: f64,
) -> Ned {
terms(own_vel, target, neighbors, t, cruise).sum()
}
pub fn terms(
own_vel: Ned,
target: Option<Ned>,
neighbors: &[NeighborState],
t: &FlockTuning,
cruise: f64,
) -> FlockTerms {
let set = NearestSet::build(neighbors, t.radius_m, t.neighbors);
let mut out = FlockTerms {
neighbors_used: set.len(),
separation: repulsion_over(neighbors, &set, &t.separation),
..FlockTerms::default()
};
let mut far_sum = Ned::ZERO;
let mut far_count = 0usize;
let mut vel_sum = Ned::ZERO;
for (i, d) in set.iter() {
vel_sum = vel_sum + neighbors[i].vel;
if d > t.separation.radius_m {
far_sum = far_sum + neighbors[i].pos;
far_count += 1;
}
}
if far_count > 0 {
let centroid = far_sum.scale(1.0 / far_count as f64);
out.cohesion = centroid.scale(t.cohesion);
}
if !set.is_empty() {
let mean_vel = vel_sum.scale(1.0 / set.len() as f64);
out.alignment = (mean_vel - own_vel).scale(t.alignment);
}
if let Some(goal) = target {
out.target = goal.scale(t.target).clamp_norm(cruise);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::separation::SEPARATION_RADIUS_M;
const CRUISE: f64 = 10.0;
fn nbr(slot: u8, pos: Ned, vel: Ned) -> NeighborState {
NeighborState::new(slot, pos, vel, 0)
}
#[test]
fn cohesion_pulls_toward_far_neighbours_only() {
let t = FlockTuning::default();
let far = terms(
Ned::ZERO,
None,
&[nbr(2, Ned::new(20.0, 0.0, 0.0), Ned::ZERO)],
&t,
CRUISE,
);
assert!((far.cohesion.n - FLOCK_COHESION * 20.0).abs() < 1e-12);
assert_eq!(far.neighbors_used, 1);
let near = terms(
Ned::ZERO,
None,
&[nbr(2, Ned::new(5.0, 0.0, 0.0), Ned::ZERO)],
&t,
CRUISE,
);
assert_eq!(near.cohesion, Ned::ZERO);
assert!(near.separation.n < 0.0);
}
#[test]
fn the_lattice_equilibrium_sits_on_the_separation_radius() {
let t = FlockTuning::default();
let radial = |d: f64| {
terms(
Ned::ZERO,
None,
&[nbr(2, Ned::new(d, 0.0, 0.0), Ned::ZERO)],
&t,
CRUISE,
)
.sum()
.n
};
assert!(
radial(SEPARATION_RADIUS_M + 0.1) > 0.0,
"beyond: pulls together"
);
assert!(
radial(SEPARATION_RADIUS_M - 0.1) < 0.0,
"inside: pushes apart"
);
assert!(radial(1.65) < 0.0);
}
#[test]
fn alignment_matches_the_mean_neighbour_velocity() {
let t = FlockTuning::default();
let ns = [
nbr(2, Ned::new(20.0, 0.0, 0.0), Ned::new(6.0, 0.0, 0.0)),
nbr(3, Ned::new(0.0, 20.0, 0.0), Ned::new(4.0, 0.0, 0.0)),
];
let out = terms(Ned::ZERO, None, &ns, &t, CRUISE);
assert!((out.alignment.n - FLOCK_ALIGNMENT * 5.0).abs() < 1e-12);
let matched = terms(Ned::new(5.0, 0.0, 0.0), None, &ns, &t, CRUISE);
assert!(matched.alignment.norm() < 1e-12, "{:?}", matched.alignment);
}
#[test]
fn the_virtual_leader_is_a_uniform_field_beyond_the_saturation_radius() {
let t = FlockTuning::default();
let lead = terms(Ned::ZERO, Some(Ned::new(500.0, 0.0, 0.0)), &[], &t, CRUISE).target;
let trail = terms(Ned::ZERO, Some(Ned::new(508.0, 0.0, 0.0)), &[], &t, CRUISE).target;
assert!((lead.norm() - CRUISE).abs() < 1e-12, "saturated at cruise");
assert!((trail.norm() - CRUISE).abs() < 1e-12);
assert!(
(lead - trail).norm() < 1e-9,
"differential compression {:?}",
lead - trail
);
let raw_lead = Ned::new(500.0, 0.0, 0.0).scale(FLOCK_TARGET);
let raw_trail = Ned::new(508.0, 0.0, 0.0).scale(FLOCK_TARGET);
assert!(((raw_trail - raw_lead).norm() - 6.4).abs() < 1e-9);
}
#[test]
fn the_virtual_leader_decays_to_zero_at_the_target() {
let t = FlockTuning::default();
let mut last = f64::INFINITY;
for r in [12.5, 8.0, 4.0, 1.0, 0.1] {
let m = terms(Ned::ZERO, Some(Ned::new(r, 0.0, 0.0)), &[], &t, CRUISE)
.target
.norm();
assert!(
m < last,
"pull must shrink as the target is reached: {m} >= {last}"
);
assert!(
(m - FLOCK_TARGET * r).abs() < 1e-12,
"proportional inside saturation"
);
last = m;
}
assert_eq!(
terms(Ned::ZERO, Some(Ned::ZERO), &[], &t, CRUISE).target,
Ned::ZERO
);
assert_eq!(terms(Ned::ZERO, None, &[], &t, CRUISE).target, Ned::ZERO);
}
#[test]
fn an_empty_neighbourhood_leaves_only_the_leader_term() {
let t = FlockTuning::default();
let out = terms(
Ned::new(1.0, 2.0, 3.0),
Some(Ned::new(0.0, 100.0, 0.0)),
&[],
&t,
CRUISE,
);
assert_eq!(out.cohesion, Ned::ZERO);
assert_eq!(out.alignment, Ned::ZERO);
assert_eq!(out.separation, Ned::ZERO);
assert_eq!(out.neighbors_used, 0);
assert!((out.target.e - CRUISE).abs() < 1e-12);
}
#[test]
fn neighbours_beyond_the_flock_radius_are_invisible() {
let t = FlockTuning::default();
let out = terms(
Ned::ZERO,
None,
&[nbr(
2,
Ned::new(FLOCK_RADIUS_M + 1.0, 0.0, 0.0),
Ned::new(9.0, 0.0, 0.0),
)],
&t,
CRUISE,
);
assert_eq!(out.neighbors_used, 0);
assert_eq!(out.sum(), Ned::ZERO);
}
#[test]
fn command_is_the_sum_of_the_terms() {
let t = FlockTuning::default();
let ns = [
nbr(2, Ned::new(20.0, 0.0, 0.0), Ned::new(1.0, 0.0, 0.0)),
nbr(3, Ned::new(-5.0, 0.0, 0.0), Ned::new(2.0, 0.0, 0.0)),
];
let target = Some(Ned::new(0.0, 40.0, -5.0));
let terms = terms(Ned::new(0.5, 0.0, 0.0), target, &ns, &t, CRUISE);
let cmd = command(Ned::new(0.5, 0.0, 0.0), target, &ns, &t, CRUISE);
assert_eq!(cmd, terms.sum());
assert!(cmd.is_finite());
}
#[test]
fn tuning_sanitises_absurd_gains_without_disabling_the_layer() {
let t = FlockTuning {
radius_m: 2.0, neighbors: 0,
cohesion: f64::NAN,
alignment: -1.0,
target: f64::INFINITY,
separation: SeparationTuning::default(),
}
.sanitised();
assert!(t.radius_m > t.separation.radius_m);
assert_eq!(t.neighbors, FLOCK_NEIGHBORS);
assert_eq!(t.cohesion, 0.0);
assert_eq!(t.alignment, 0.0);
assert_eq!(t.target, 0.0);
assert!(command(Ned::ZERO, Some(Ned::new(100.0, 0.0, 0.0)), &[], &t, CRUISE).is_finite());
}
}