use tracing::warn;
pub(super) fn clamp_axis(value: f32, min: f32, max: f32, axis: &str) -> f32 {
if value.is_nan() {
return value;
}
let clamped = value.clamp(min, max);
if (clamped - value).abs() > f32::EPSILON {
warn!(
"Clamping {} from {:.2}° to {:.2}° (limits {:.2}°..={:.2}°)",
axis, value, clamped, min, max
);
}
clamped
}
pub(super) fn quaternion_wxyz_to_euler_deg(q: [f32; 4]) -> (f32, f32, f32) {
let w = q[0];
let x = q[1];
let y = q[2];
let z = q[3];
let sinr_cosp = 2.0 * (w * x + y * z);
let cosr_cosp = 1.0 - 2.0 * (x * x + y * y);
let roll = sinr_cosp.atan2(cosr_cosp);
let sinp = 2.0 * (w * y - z * x);
let pitch = if sinp.abs() >= 1.0 {
std::f32::consts::FRAC_PI_2.copysign(sinp)
} else {
sinp.asin()
};
let siny_cosp = 2.0 * (w * z + x * y);
let cosy_cosp = 1.0 - 2.0 * (y * y + z * z);
let yaw = siny_cosp.atan2(cosy_cosp);
(pitch.to_degrees(), roll.to_degrees(), yaw.to_degrees())
}
pub(super) fn euler_deg_to_quaternion_wxyz(
pitch_deg: f32,
roll_deg: f32,
yaw_deg: f32,
) -> [f32; 4] {
let (hp, hr, hy) = (
pitch_deg.to_radians() / 2.0,
roll_deg.to_radians() / 2.0,
yaw_deg.to_radians() / 2.0,
);
let (cp, sp) = (hp.cos(), hp.sin());
let (cr, sr) = (hr.cos(), hr.sin());
let (cy, sy) = (hy.cos(), hy.sin());
[
cr * cp * cy + sr * sp * sy,
sr * cp * cy - cr * sp * sy,
cr * sp * cy + sr * cp * sy,
cr * cp * sy - sr * sp * cy,
]
}
pub(super) fn compute_rate_target_deg(current_deg: f32, rate_deg_per_s: f32, dt: f32) -> f32 {
if !rate_deg_per_s.is_finite() || rate_deg_per_s == 0.0 {
return current_deg;
}
current_deg + rate_deg_per_s * dt
}
pub(super) fn clamp_rate_dt(dt: std::time::Duration) -> std::time::Duration {
const MIN: std::time::Duration = std::time::Duration::from_millis(10);
const MAX: std::time::Duration = std::time::Duration::from_millis(500);
dt.clamp(MIN, MAX)
}
pub(super) fn wrap_180_deg(deg: f32) -> f32 {
let mut x = deg % 360.0;
if x > 180.0 {
x -= 360.0;
} else if x < -180.0 {
x += 360.0;
}
x
}
pub(super) fn earth_to_vehicle_yaw_deg(earth_yaw_deg: f32, vehicle_yaw_deg: f32) -> f32 {
wrap_180_deg(earth_yaw_deg - vehicle_yaw_deg)
}
pub(super) fn to_id_byte(v: f32) -> u8 {
if !v.is_finite() || v < 0.0 {
return 0;
}
let r = v.round();
if r > 255.0 {
255
} else {
r as u8
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn compute_rate_target_integrates_constant_rate() {
let target = compute_rate_target_deg(10.0, 30.0, 0.1);
assert!((target - 13.0).abs() < 1e-4);
}
#[test]
fn compute_rate_target_negative_rate_decreases() {
let target = compute_rate_target_deg(5.0, -45.0, 0.2);
assert!((target - -4.0).abs() < 1e-4);
}
#[test]
fn compute_rate_target_zero_rate_is_noop() {
assert_eq!(compute_rate_target_deg(42.0, 0.0, 0.5), 42.0);
}
#[test]
fn compute_rate_target_nan_rate_is_noop() {
assert_eq!(compute_rate_target_deg(42.0, f32::NAN, 0.5), 42.0);
}
#[test]
fn clamp_rate_dt_pins_below_min() {
let clamped = clamp_rate_dt(std::time::Duration::from_micros(1));
assert_eq!(clamped, std::time::Duration::from_millis(10));
}
#[test]
fn clamp_rate_dt_pins_above_max() {
let clamped = clamp_rate_dt(std::time::Duration::from_secs(5));
assert_eq!(clamped, std::time::Duration::from_millis(500));
}
#[test]
fn clamp_rate_dt_passes_through_in_band() {
let dt = std::time::Duration::from_millis(100);
assert_eq!(clamp_rate_dt(dt), dt);
}
#[test]
fn wrap_180_passes_through_in_band() {
assert!((wrap_180_deg(0.0) - 0.0).abs() < 1e-4);
assert!((wrap_180_deg(90.0) - 90.0).abs() < 1e-4);
assert!((wrap_180_deg(-179.9) - -179.9).abs() < 1e-4);
assert!((wrap_180_deg(180.0) - 180.0).abs() < 1e-4);
}
#[test]
fn wrap_180_normalises_above_180() {
assert!((wrap_180_deg(340.0) - -20.0).abs() < 1e-4);
assert!((wrap_180_deg(181.0) - -179.0).abs() < 1e-4);
}
#[test]
fn wrap_180_normalises_below_neg_180() {
assert!((wrap_180_deg(-340.0) - 20.0).abs() < 1e-4);
assert!((wrap_180_deg(-181.0) - 179.0).abs() < 1e-4);
}
#[test]
fn earth_to_vehicle_yaw_subtracts_vehicle_heading() {
assert!((earth_to_vehicle_yaw_deg(90.0, 90.0) - 0.0).abs() < 1e-4);
assert!((earth_to_vehicle_yaw_deg(0.0, 90.0) - -90.0).abs() < 1e-4);
}
#[test]
fn earth_to_vehicle_yaw_wraps_correctly() {
assert!((earth_to_vehicle_yaw_deg(170.0, -170.0) - -20.0).abs() < 1e-4);
assert!((earth_to_vehicle_yaw_deg(-170.0, 170.0) - 20.0).abs() < 1e-4);
}
#[test]
fn to_id_byte_clamps_and_rejects_special_values() {
assert_eq!(to_id_byte(7.0), 7);
assert_eq!(to_id_byte(7.4), 7);
assert_eq!(to_id_byte(7.6), 8);
assert_eq!(to_id_byte(0.0), 0);
assert_eq!(to_id_byte(300.0), 255);
assert_eq!(to_id_byte(-1.0), 0);
assert_eq!(to_id_byte(-3.0), 0);
assert_eq!(to_id_byte(f32::NAN), 0);
assert_eq!(to_id_byte(f32::INFINITY), 0);
assert_eq!(to_id_byte(f32::NEG_INFINITY), 0);
}
fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() < eps
}
#[test]
fn quaternion_wxyz_identity_is_zero_euler() {
let (pitch, roll, yaw) = quaternion_wxyz_to_euler_deg([1.0, 0.0, 0.0, 0.0]);
assert!(approx_eq(pitch, 0.0, 1e-4));
assert!(approx_eq(roll, 0.0, 1e-4));
assert!(approx_eq(yaw, 0.0, 1e-4));
}
#[test]
fn quaternion_wxyz_roundtrips_per_axis() {
for (p, r, y) in [(30.0, 0.0, 0.0), (0.0, 45.0, 0.0), (0.0, 0.0, 60.0)] {
let q = euler_deg_to_quaternion_wxyz(p, r, y);
let (pp, rr, yy) = quaternion_wxyz_to_euler_deg(q);
assert!(approx_eq(pp, p, 1e-3), "pitch: got {}, want {}", pp, p);
assert!(approx_eq(rr, r, 1e-3), "roll: got {}, want {}", rr, r);
assert!(approx_eq(yy, y, 1e-3), "yaw: got {}, want {}", yy, y);
}
}
#[test]
fn quaternion_wxyz_handles_pitch_near_gimbal_lock() {
let q = euler_deg_to_quaternion_wxyz(90.0, 0.0, 0.0);
let (pitch, _roll, _yaw) = quaternion_wxyz_to_euler_deg(q);
assert!(
approx_eq(pitch, 90.0, 5e-2),
"pitch: got {}, want ~90",
pitch
);
}
#[test]
fn clamp_axis_passes_in_range_value() {
assert_eq!(clamp_axis(45.0, -90.0, 90.0, "pitch"), 45.0);
assert_eq!(clamp_axis(-89.999, -90.0, 90.0, "pitch"), -89.999);
assert_eq!(clamp_axis(0.0, -90.0, 90.0, "pitch"), 0.0);
}
#[test]
fn clamp_axis_clamps_out_of_range() {
assert_eq!(clamp_axis(120.0, -90.0, 90.0, "pitch"), 90.0);
assert_eq!(clamp_axis(-200.0, -180.0, 180.0, "yaw"), -180.0);
}
#[test]
fn clamp_axis_passes_nan_through() {
assert!(clamp_axis(f32::NAN, -90.0, 90.0, "pitch").is_nan());
}
#[test]
fn quaternion_wxyz_clamps_non_unit_beyond_sin_range() {
let q = [0.8, 0.0, 0.8, 0.0];
let (pitch, _roll, _yaw) = quaternion_wxyz_to_euler_deg(q);
assert!(pitch.is_finite(), "pitch should be clamped, got {}", pitch);
assert!(approx_eq(pitch, 90.0, 1e-3));
}
}