#[must_use]
pub fn braking_distance(velocity: f64, deceleration: f64) -> f64 {
if deceleration <= 0.0 {
return 0.0;
}
let speed = velocity.abs();
speed * speed / (2.0 * deceleration)
}
#[derive(Debug, Clone, Copy)]
pub struct MovementResult {
pub position: f64,
pub velocity: f64,
pub arrived: bool,
}
#[must_use]
pub fn tick_movement(
position: f64,
velocity: f64,
target_position: f64,
max_speed: f64,
acceleration: f64,
deceleration: f64,
dt: f64,
) -> MovementResult {
const EPSILON: f64 = 1e-9;
let displacement = target_position - position;
if displacement.abs() < EPSILON && velocity.abs() < EPSILON {
return MovementResult {
position: target_position,
velocity: 0.0,
arrived: true,
};
}
let sign = displacement.signum();
let distance_remaining = displacement.abs();
let speed = velocity.abs();
let safe_decel = deceleration.max(EPSILON);
let stopping_distance = speed * speed / (2.0 * safe_decel);
let opposing = velocity * sign < 0.0;
let new_velocity = if opposing || stopping_distance >= distance_remaining - EPSILON {
let v = crate::fp::fma(-safe_decel * dt, velocity.signum(), velocity);
if velocity > 0.0 && v < 0.0 || velocity < 0.0 && v > 0.0 {
0.0
} else {
v
}
} else if speed < max_speed {
let v = crate::fp::fma(acceleration * dt, sign, velocity);
if v.abs() > max_speed {
sign * max_speed
} else {
v
}
} else {
sign * max_speed
};
let new_pos = crate::fp::fma(new_velocity, dt, position);
let new_displacement = target_position - new_pos;
if new_displacement.abs() < EPSILON || (new_displacement.signum() - sign).abs() > EPSILON {
return MovementResult {
position: target_position,
velocity: 0.0,
arrived: true,
};
}
MovementResult {
position: new_pos,
velocity: new_velocity,
arrived: false,
}
}
#[cfg(feature = "loop_lines")]
#[must_use]
#[allow(
clippy::too_many_arguments,
reason = "mirrors tick_movement plus circumference; passing a struct adds boilerplate without clarifying the call site"
)]
pub fn tick_movement_cyclic(
position: f64,
velocity: f64,
target_position: f64,
max_speed: f64,
acceleration: f64,
deceleration: f64,
dt: f64,
circumference: f64,
) -> MovementResult {
use crate::components::cyclic::{forward_distance, wrap_position};
if !circumference.is_finite() || circumference <= 0.0 {
return MovementResult {
position,
velocity: 0.0,
arrived: false,
};
}
let pos = wrap_position(position, circumference);
let target = wrap_position(target_position, circumference);
let fwd = forward_distance(pos, target, circumference);
let linear = tick_movement(
0.0,
velocity,
fwd,
max_speed,
acceleration,
deceleration,
dt,
);
if linear.arrived {
return MovementResult {
position: target,
velocity: 0.0,
arrived: true,
};
}
let new_pos = wrap_position(pos + linear.position, circumference);
MovementResult {
position: new_pos,
velocity: linear.velocity,
arrived: false,
}
}
#[cfg(feature = "loop_lines")]
#[must_use]
pub fn headway_clamp_target(
trailer_position: f64,
leader_position: f64,
intended_target: f64,
min_headway: f64,
circumference: f64,
) -> f64 {
use crate::components::cyclic::{forward_distance, wrap_position};
if !circumference.is_finite() || circumference <= 0.0 || !min_headway.is_finite() {
return intended_target;
}
let trailer = wrap_position(trailer_position, circumference);
let leader = wrap_position(leader_position, circumference);
let intended = wrap_position(intended_target, circumference);
let gap = forward_distance(trailer, leader, circumference);
let safe_advance = (gap - min_headway).max(0.0);
let intended_advance = forward_distance(trailer, intended, circumference);
debug_assert!(
safe_advance >= 0.0,
"headway clamp produced negative safe_advance: gap={gap} min_headway={min_headway}"
);
if intended_advance <= safe_advance {
intended
} else {
wrap_position(trailer + safe_advance, circumference)
}
}
#[cfg(all(test, feature = "loop_lines"))]
#[allow(
clippy::panic,
reason = "test-only assertions; production-code lint doesn't apply"
)]
mod cyclic_tests {
use super::*;
use crate::components::cyclic::forward_distance;
const MAX_SPEED: f64 = 5.0;
const ACCEL: f64 = 2.0;
const DECEL: f64 = 2.0;
const DT: f64 = 1.0;
const C: f64 = 100.0;
fn approx(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < 1e-9,
"expected {expected}, got {actual}",
);
}
#[test]
fn cyclic_arrival_at_target_returns_arrived() {
let r = tick_movement_cyclic(50.0, 0.0, 50.0, MAX_SPEED, ACCEL, DECEL, DT, C);
approx(r.position, 50.0);
approx(r.velocity, 0.0);
assert!(r.arrived);
}
#[test]
fn cyclic_handles_seam_crossing_in_one_tick() {
let r = tick_movement_cyclic(95.0, 5.0, 5.0, MAX_SPEED, ACCEL, DECEL, DT, C);
assert!(
(0.0..C).contains(&r.position),
"post-seam position {} out of [0, {C})",
r.position
);
let new_fwd = forward_distance(r.position, 5.0, C);
let old_fwd = forward_distance(95.0, 5.0, C);
assert!(
new_fwd < old_fwd,
"forward distance must decrease: {old_fwd} → {new_fwd}",
);
}
#[test]
fn cyclic_full_lap_eventually_arrives() {
let mut pos = 0.0;
let mut vel = 0.0;
let target = 99.0;
for _ in 0..200 {
let r = tick_movement_cyclic(pos, vel, target, MAX_SPEED, ACCEL, DECEL, DT, C);
pos = r.position;
vel = r.velocity;
if r.arrived {
approx(pos, target);
return;
}
}
panic!("car failed to arrive within 200 ticks; final pos={pos} vel={vel}");
}
#[test]
fn cyclic_through_seam_eventually_arrives() {
let mut pos = 80.0;
let mut vel = 0.0;
let target = 20.0;
let mut crossed_seam = false;
for _ in 0..200 {
let prev = pos;
let r = tick_movement_cyclic(pos, vel, target, MAX_SPEED, ACCEL, DECEL, DT, C);
if r.position < prev && !r.arrived {
crossed_seam = true;
}
pos = r.position;
vel = r.velocity;
if r.arrived {
approx(pos, target);
assert!(
crossed_seam,
"arrival without seam crossing implies wrong-way travel"
);
return;
}
}
panic!("did not arrive within 200 ticks");
}
#[test]
fn cyclic_degenerate_circumference_returns_input() {
let r = tick_movement_cyclic(50.0, 3.0, 60.0, MAX_SPEED, ACCEL, DECEL, DT, 0.0);
approx(r.position, 50.0);
approx(r.velocity, 0.0);
assert!(!r.arrived);
}
#[test]
fn headway_clamp_passes_through_when_gap_is_large() {
let intended = 30.0;
let clamped = headway_clamp_target(0.0, 80.0, intended, 5.0, C);
approx(clamped, 30.0);
}
#[test]
fn headway_clamp_caps_target_at_leader_minus_headway() {
let clamped = headway_clamp_target(0.0, 10.0, 20.0, 5.0, C);
approx(clamped, 5.0);
}
#[test]
fn headway_clamp_collapses_to_self_when_gap_is_zero() {
let clamped = headway_clamp_target(50.0, 50.0, 60.0, 5.0, C);
approx(clamped, 50.0);
}
#[test]
fn headway_clamp_handles_seam_crossing_gap() {
let clamped = headway_clamp_target(95.0, 5.0, 4.0, 2.0, C);
approx(clamped, 3.0);
}
#[test]
fn headway_clamp_degenerate_inputs_pass_through() {
approx(headway_clamp_target(0.0, 50.0, 30.0, 5.0, 0.0), 30.0);
approx(headway_clamp_target(0.0, 50.0, 30.0, f64::NAN, C), 30.0);
}
fn lcg_next(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
#[allow(
clippy::cast_precision_loss,
reason = "mapping the top 53 bits of u64 into [0, 1) is the standard f64 quantisation"
)]
let upper = (*state >> 11) as f64;
upper / ((1u64 << 53) as f64)
}
#[test]
fn property_headway_invariant_holds_across_random_ticks() {
const N_CARS: usize = 4;
const HEADWAY: f64 = 5.0;
const TICKS: u32 = 10_000;
let mut positions = [0.0_f64, 25.0, 50.0, 75.0];
let mut velocities = [0.0_f64; N_CARS];
let mut targets = [10.0_f64, 35.0, 60.0, 85.0];
let mut rng = 0x00C0_FFEE_u64;
for tick in 0..TICKS {
if tick % 73 == 0 {
let i = (lcg_next(&mut rng) * N_CARS as f64) as usize % N_CARS;
let advance = lcg_next(&mut rng).mul_add(30.0, 5.0);
targets[i] = (positions[i] + advance) % C;
}
let mut order: [usize; N_CARS] = [0, 1, 2, 3];
order.sort_by(|&a, &b| {
let da = forward_distance(positions[0], positions[a], C);
let db = forward_distance(positions[0], positions[b], C);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
for k in 0..N_CARS {
let me = order[k];
let leader = order[(k + 1) % N_CARS];
let clamped =
headway_clamp_target(positions[me], positions[leader], targets[me], HEADWAY, C);
let r = tick_movement_cyclic(
positions[me],
velocities[me],
clamped,
MAX_SPEED,
ACCEL,
DECEL,
DT,
C,
);
positions[me] = r.position;
velocities[me] = r.velocity;
}
order.sort_by(|&a, &b| {
let da = forward_distance(positions[0], positions[a], C);
let db = forward_distance(positions[0], positions[b], C);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
for k in 0..N_CARS {
let me = order[k];
let leader = order[(k + 1) % N_CARS];
let gap = forward_distance(positions[me], positions[leader], C);
assert!(
gap >= HEADWAY - 1e-6,
"tick {tick}: headway invariant violated: car {me} → leader {leader} gap={gap} < {HEADWAY}",
);
}
}
}
}