Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Scale-aware multirotor rank validation.** The allocation round-trip tolerance now follows the
scalar epsilon and matrix condition scale, so large valid layouts receive the same answer at
`f32` and `f64` while rank-deficient layouts remain rejected.

- **Large-circle occupancy rims.** `occupy_circle` now derives its sample count from a maximum
arc length relative to grid resolution, replacing its fixed minimum angular step so large rims
remain closed to ray casts. Non-finite radii now leave the map unchanged. (#296)
Expand Down
13 changes: 9 additions & 4 deletions crates/multicalc/src/plant/multirotor_mixing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ impl<const ROTOR_COUNT: usize, T: Numeric> MultirotorMixer<ROTOR_COUNT, T> {
/// [`PlantError::Linalg`] if the layout cannot be inverted, or
/// [`PlantError::RotorLayoutNotIndependent`] if the rotors cannot produce every wanted push
/// and turn — which is what happens with fewer than four rotors, or with rotors all in a line.
/// The independence check accounts for the scalar precision and the allocation's numerical
/// scale, so equivalent layouts receive the same answer at `f32` and `f64`.
pub fn new(
positions: [Vector3D<T>; ROTOR_COUNT],
spins: [RotorSpin; ROTOR_COUNT],
Expand Down Expand Up @@ -161,11 +163,14 @@ impl<const ROTOR_COUNT: usize, T: Numeric> MultirotorMixer<ROTOR_COUNT, T> {
let distribution = allocation.pseudo_inverse()?;

// Going out to the rotors and back has to land where it started; when it does not, some
// wanted push or turn is one the rotors simply cannot produce. The bar is loose enough
// that single precision passes and tight enough that a missing direction, which is off by
// about one, always fails.
// wanted push or turn is one the rotors simply cannot produce. Matrix multiplication error
// grows with the scalar epsilon, the inner dimension, and the norms of both factors. Cap
// the resulting forward-error bound below the order-one error of a missing direction.
let round_trip = allocation * distribution;
let bar = T::from_f64(1e-4);
let operation_count = T::from_usize(ROTOR_COUNT.max(4));
let condition_scale = allocation.frobenius_norm() * distribution.frobenius_norm();
let bar =
(T::EPSILON * operation_count * condition_scale.max(T::ONE)).min(T::from_f64(0.25));
for row in 0..4 {
for col in 0..4 {
let wanted = if row == col { T::ONE } else { T::ZERO };
Expand Down
48 changes: 48 additions & 0 deletions crates/multicalc/tests/suite/plant/multirotor_mixing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,54 @@ fn single_precision_round_trips_too() {
assert!(produced.torque().norm() < 1e-4);
}

fn assert_scaled_layout_is_accepted<T: Numeric>() {
let mixer = MultirotorMixer::<4, T>::quadrotor_x(
T::from_f64(15_000.0),
T::from_f64(1_600.0),
T::from_f64(MINIMUM_THRUST),
T::from_f64(MAXIMUM_THRUST),
);
assert!(mixer.is_ok());
}

#[test]
fn scaled_layout_is_accepted_at_both_precisions() {
assert_scaled_layout_is_accepted::<f32>();
assert_scaled_layout_is_accepted::<f64>();
}

fn assert_rank_deficient_layout_is_refused<T: Numeric>() {
let positions = [
Vector::new([T::from_f64(0.1), T::ZERO, T::ZERO]),
Vector::new([T::from_f64(0.2), T::ZERO, T::ZERO]),
Vector::new([T::from_f64(-0.1), T::ZERO, T::ZERO]),
Vector::new([T::from_f64(-0.2), T::ZERO, T::ZERO]),
];
let spins = [
RotorSpin::Clockwise,
RotorSpin::CounterClockwise,
RotorSpin::Clockwise,
RotorSpin::CounterClockwise,
];

assert_eq!(
MultirotorMixer::<4, T>::new(
positions,
spins,
T::from_f64(TORQUE_PER_THRUST),
T::from_f64(MINIMUM_THRUST),
T::from_f64(MAXIMUM_THRUST),
),
Err(PlantError::RotorLayoutNotIndependent)
);
}

#[test]
fn rank_deficient_layout_is_refused_at_both_precisions() {
assert_rank_deficient_layout_is_refused::<f32>();
assert_rank_deficient_layout_is_refused::<f64>();
}

#[test]
fn a_mixer_drives_a_body() {
let mass = 0.8;
Expand Down
Loading