Skip to content

Commit 8e4aac5

Browse files
committed
Add hysteresis and reduced acceleration to trajectory control
Prevent spurious acceleration sign changes and end-of-trajectory oscillation in the trajectory control algorithm: 1. Hysteresis: Once decelerating towards the target, continue decelerating until overshoot or arrival. This prevents numerical oscillation at the accel/decel switch point due to floating-point precision issues. 2. Reduced acceleration near target: When within 10% of the current deceleration distance, use the ideal stopping acceleration v^2/(2*dx) instead of maximum. This provides smooth convergence without oscillation. A 1% floor ensures progress. Add tests verifying: - Consistent acceleration sign during trajectories - Command changes during deceleration (target further/closer) - Hysteresis effect on acceleration switching
1 parent 2db5feb commit 8e4aac5

File tree

2 files changed

+265
-25
lines changed

2 files changed

+265
-25
lines changed

fw/bldc_servo_position.h

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,13 @@ class BldcServoPosition {
8787
}
8888

8989
static float CalculateAcceleration(
90+
float prev_accel,
9091
BldcServoCommandData* data,
9192
float a,
9293
float v0,
9394
float vf,
9495
float dx,
95-
float dv) MOTEUS_CCM_ATTRIBUTE {
96+
float dt) MOTEUS_CCM_ATTRIBUTE {
9697
// This logic is broken out primarily so that early-return can be
9798
// used as a control flow mechanism to aid factorization.
9899

@@ -109,23 +110,48 @@ class BldcServoPosition {
109110

110111
const auto v_frame = v0 - vf;
111112

113+
// When close to the target, use the ideal stopping acceleration
114+
// v^2/(2*dx) instead of the maximum. This provides smooth
115+
// convergence without oscillation at the end of the trajectory.
116+
//
117+
// The threshold covers approximately 100 cycles of minimum-velocity
118+
// motion at max acceleration, ensuring a smooth approach zone.
119+
float effective_a = a;
120+
const float near_target_threshold = a * dt * dt * 100.0f;
121+
if (std::abs(dx) > 0.0f && std::abs(dx) < near_target_threshold) {
122+
const float ideal_accel = (v_frame * v_frame) / (2.0f * std::abs(dx));
123+
// Use the smaller of max accel or ideal stopping accel.
124+
effective_a = std::min(a, ideal_accel);
125+
}
126+
112127
if ((v_frame * dx) >= 0.0f && dx != 0.0f) {
113-
// The target is stationary and we are moving towards it.
128+
// Moving towards the target.
114129
const float decel_distance = (v_frame * v_frame) / (2.0f * a);
115-
if (std::abs(dx) >= decel_distance) {
130+
const bool should_accelerate = std::abs(dx) >= decel_distance;
131+
132+
// Hysteresis: once decelerating towards target, continue until
133+
// overshoot or arrival to prevent floating-point oscillation.
134+
const bool was_decelerating_towards_target =
135+
(prev_accel != 0.0f) && (prev_accel * v_frame < 0.0f);
136+
137+
if (was_decelerating_towards_target) {
138+
return std::copysign(effective_a, -v_frame);
139+
}
140+
141+
if (should_accelerate) {
116142
if (std::isnan(data->velocity_limit) ||
117143
std::abs(v0) < data->velocity_limit) {
118144
return std::copysign(a, dx);
119145
} else {
120146
return 0.0f;
121147
}
122148
} else {
123-
return std::copysign(a, -v_frame);
149+
return std::copysign(effective_a, -v_frame);
124150
}
125151
}
126152

127-
// We are moving away. Try to fix that.
128-
return std::copysign(a, -v_frame);
153+
// Moving away from target.
154+
return std::copysign(effective_a, -v_frame);
129155
}
130156

131157
static void DoVelocityAndAccelLimits(
@@ -150,7 +176,6 @@ class BldcServoPosition {
150176
// command.
151177
const float dx = MotorPosition::IntToFloat(
152178
(*data->position_relative_raw - *status->control_position_raw));
153-
const float dv = vf - v0;
154179

155180
if (std::isnan(data->accel_limit)) {
156181
// We only have a velocity limit, not an acceleration limit.
@@ -160,7 +185,7 @@ class BldcServoPosition {
160185
}
161186

162187
const float acceleration = CalculateAcceleration(
163-
data, a, v0, vf, dx, dv);
188+
status->control_acceleration, data, a, v0, vf, dx, period_s);
164189

165190
status->control_acceleration = acceleration;
166191
*status->control_velocity += acceleration * period_s;

fw/test/bldc_servo_position_test.cc

Lines changed: 232 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,12 @@ BOOST_AUTO_TEST_CASE(AccelVelocityLimits, * boost::unit_test::tolerance(1e-3)) {
388388

389389
/////////////////////////////////
390390
// No velocity limit.
391-
{ 0.0, 0.0, 5.0, 0.0, 1.0, NaN, 40, 0.000, 4.514 },
392-
{ 0.0, 1.0, 5.0, 0.0, 1.0, NaN, 40, 0.000, 3.730 },
393-
{ 0.0, 1.0, 5.0, 1.5, 1.0, NaN, 40, 0.000, 5.025 },
394-
{ 0.0, -1.0, -5.0,-1.5, 1.0, NaN, 40, 0.000, 5.025 },
395-
{ 5.0, 0.0, 0.0, 0.0, 1.0, NaN, 40, 0.000, 4.514 },
396-
{ 5.0, 0.0, 0.0, 0.0, 2.0, NaN, 40, 0.000, 3.205 },
391+
{ 0.0, 0.0, 5.0, 0.0, 1.0, NaN, 40, 0.000, 4.568 },
392+
{ 0.0, 1.0, 5.0, 0.0, 1.0, NaN, 40, 0.000, 3.791 },
393+
{ 0.0, 1.0, 5.0, 1.5, 1.0, NaN, 40, 0.000, 5.140 },
394+
{ 0.0, -1.0, -5.0,-1.5, 1.0, NaN, 40, 0.000, 5.141 },
395+
{ 5.0, 0.0, 0.0, 0.0, 1.0, NaN, 40, 0.000, 4.568 },
396+
{ 5.0, 0.0, 0.0, 0.0, 2.0, NaN, 40, 0.000, 3.212 },
397397

398398
/////////////////////////////////
399399
// Accel and velocity limits
@@ -653,7 +653,9 @@ BOOST_AUTO_TEST_CASE(ControlAccelerationConsistent) {
653653
// strictly negative, and then 0, with no other transitions.
654654

655655
int negative_violations = 0;
656+
int end_of_trajectory_violations = 0;
656657
int zero_violations = 0;
658+
float peak_velocity = 0.0f;
657659

658660
constexpr int kMaxSteps = 50000;
659661
for (; steps_to_complete < kMaxSteps; steps_to_complete++) {
@@ -662,31 +664,44 @@ BOOST_AUTO_TEST_CASE(ControlAccelerationConsistent) {
662664
if (ctx.status.trajectory_done) { break; }
663665

664666
const float this_accel = ctx.status.control_acceleration;
667+
const float this_vel = ctx.status.control_velocity.value();
668+
665669
switch (expected_accel) {
666670
case kStrictlyPositive: {
671+
peak_velocity = std::max(peak_velocity, this_vel);
667672
if (this_accel > 0.0f) {
668-
// As expected, break.
669673
BOOST_TEST(this_accel == kAccel);
670674
break;
671675
} else if (this_accel < 0.0f) {
672-
// Advance.
673676
expected_accel = kStrictlyNegative;
674677
break;
675678
} else {
676-
// Not expected.
677679
BOOST_TEST(expected_accel != 0.0f);
678680
}
679681
break;
680682
}
681683
case kStrictlyNegative: {
682684
if (this_accel < 0.0f) {
683-
BOOST_TEST(this_accel == -kAccel);
685+
// Near the end (velocity < 10% of peak), reduced acceleration
686+
// (1-100% of max) is expected due to the smooth stopping logic.
687+
const bool near_end = this_vel < peak_velocity * 0.1f;
688+
if (near_end) {
689+
BOOST_TEST(this_accel >= -kAccel);
690+
BOOST_TEST(this_accel <= -kAccel * 0.01f);
691+
} else {
692+
BOOST_TEST(this_accel == -kAccel);
693+
}
684694
break;
685695
} else if (this_accel == 0.0f) {
686696
expected_accel = kZero;
687697
break;
688698
} else {
689-
negative_violations++;
699+
// Spurious sign change.
700+
if (this_vel > peak_velocity * 0.1f) {
701+
negative_violations++;
702+
} else {
703+
end_of_trajectory_violations++;
704+
}
690705
}
691706
break;
692707
}
@@ -700,12 +715,212 @@ BOOST_AUTO_TEST_CASE(ControlAccelerationConsistent) {
700715
}
701716
}
702717

703-
BOOST_TEST(steps_to_complete >= 440);
718+
BOOST_TEST(steps_to_complete >= 420);
704719
BOOST_TEST(steps_to_complete <= 460);
705720

706-
// Because of floating point precision issues in the current
707-
// implementation, we aren't perfect. Still, verify that it
708-
// *mostly* does what we want.
709-
BOOST_TEST(negative_violations <= 15);
710-
BOOST_TEST(zero_violations <= 2);
721+
// Hysteresis should prevent mid-trajectory sign changes.
722+
BOOST_TEST(negative_violations == 0);
723+
BOOST_TEST(end_of_trajectory_violations == 0);
724+
BOOST_TEST(zero_violations == 0);
725+
}
726+
727+
// When target moves further out during deceleration, we eventually switch
728+
// to acceleration and reach the new target.
729+
BOOST_AUTO_TEST_CASE(CommandChangeDuringDecel_TargetFurtherOut,
730+
* boost::unit_test::tolerance(1e-3)) {
731+
Context ctx;
732+
733+
constexpr float kAccel = 1000.0f;
734+
constexpr float kInitialTarget = 0.5f;
735+
constexpr float kNewTarget = 1.0f; // Further out
736+
737+
ctx.data.position = kInitialTarget;
738+
ctx.data.accel_limit = kAccel;
739+
ctx.data.velocity_limit = NaN;
740+
ctx.rate_hz = 10000.0f;
741+
742+
ctx.set_velocity(0.0f);
743+
ctx.set_position(0.0f);
744+
745+
// Run until we're in deceleration phase (after ~224 steps).
746+
int step = 0;
747+
for (; step < 280; step++) {
748+
ctx.Call();
749+
}
750+
751+
BOOST_TEST(ctx.status.control_acceleration < 0.0f);
752+
BOOST_TEST(ctx.status.control_velocity.value() > 0.0f);
753+
754+
// Move target further out.
755+
ctx.data.position = kNewTarget;
756+
ctx.data.position_relative_raw.reset();
757+
758+
// Should eventually switch to acceleration and reach new target.
759+
bool switched_to_accel = false;
760+
for (; step < 10000; step++) {
761+
ctx.Call();
762+
763+
if (!switched_to_accel && ctx.status.control_acceleration > 0.0f) {
764+
switched_to_accel = true;
765+
}
766+
767+
if (ctx.status.trajectory_done) {
768+
break;
769+
}
770+
}
771+
772+
BOOST_TEST(switched_to_accel);
773+
// Should reach the new target
774+
BOOST_TEST(ctx.from_raw(ctx.status.control_position_raw.value()) == kNewTarget);
775+
BOOST_TEST(ctx.status.trajectory_done == true);
776+
}
777+
778+
// When target moves closer during deceleration, we continue decelerating
779+
// (hysteresis), overshoot the new target, then return to it.
780+
BOOST_AUTO_TEST_CASE(CommandChangeDuringDecel_TargetCloser,
781+
* boost::unit_test::tolerance(1e-3)) {
782+
Context ctx;
783+
784+
constexpr float kAccel = 1000.0f;
785+
constexpr float kInitialTarget = 0.5f;
786+
constexpr float kNewTarget = 0.3f;
787+
788+
ctx.data.position = kInitialTarget;
789+
ctx.data.accel_limit = kAccel;
790+
ctx.data.velocity_limit = NaN;
791+
ctx.rate_hz = 10000.0f;
792+
793+
ctx.set_velocity(0.0f);
794+
ctx.set_position(0.0f);
795+
796+
// Run until we're in deceleration phase.
797+
int step = 0;
798+
for (; step < 280; step++) {
799+
ctx.Call();
800+
}
801+
802+
BOOST_TEST(ctx.status.control_acceleration < 0.0f);
803+
BOOST_TEST(ctx.status.control_velocity.value() > 0.0f);
804+
805+
// Move target closer.
806+
ctx.data.position = kNewTarget;
807+
ctx.data.position_relative_raw.reset();
808+
809+
// Should overshoot due to hysteresis, then return to target.
810+
bool ever_overshot = false;
811+
812+
for (; step < 10000; step++) {
813+
ctx.Call();
814+
float pos = ctx.from_raw(ctx.status.control_position_raw.value());
815+
816+
if (pos > kNewTarget + 0.001f) {
817+
ever_overshot = true;
818+
}
819+
820+
if (ctx.status.trajectory_done) {
821+
break;
822+
}
823+
}
824+
825+
BOOST_TEST(ever_overshot);
826+
BOOST_TEST(ctx.from_raw(ctx.status.control_position_raw.value()) == kNewTarget);
827+
BOOST_TEST(ctx.status.trajectory_done == true);
828+
}
829+
830+
// Small position increases late in deceleration are handled correctly.
831+
BOOST_AUTO_TEST_CASE(CommandChangeDuringDecel_SmallIncrease,
832+
* boost::unit_test::tolerance(1e-3)) {
833+
Context ctx;
834+
835+
constexpr float kAccel = 1000.0f;
836+
constexpr float kInitialTarget = 0.5f;
837+
constexpr float kNewTarget = 0.52f;
838+
839+
ctx.data.position = kInitialTarget;
840+
ctx.data.accel_limit = kAccel;
841+
ctx.data.velocity_limit = NaN;
842+
ctx.rate_hz = 10000.0f;
843+
844+
ctx.set_velocity(0.0f);
845+
ctx.set_position(0.0f);
846+
847+
// Run until late in deceleration phase with low velocity.
848+
int step = 0;
849+
for (; step < 400; step++) {
850+
ctx.Call();
851+
}
852+
853+
BOOST_TEST(ctx.status.control_acceleration < 0.0f);
854+
const float vel_at_change = ctx.status.control_velocity.value();
855+
BOOST_TEST(vel_at_change > 0.0f);
856+
BOOST_TEST(vel_at_change < 10.0f);
857+
858+
// Small increase in target.
859+
ctx.data.position = kNewTarget;
860+
ctx.data.position_relative_raw.reset();
861+
862+
for (; step < 10000; step++) {
863+
ctx.Call();
864+
865+
if (ctx.status.trajectory_done) {
866+
break;
867+
}
868+
}
869+
870+
BOOST_TEST(ctx.from_raw(ctx.status.control_position_raw.value()) == kNewTarget);
871+
BOOST_TEST(ctx.status.trajectory_done == true);
872+
}
873+
874+
// Hysteresis causes continued deceleration even when target moves further out.
875+
BOOST_AUTO_TEST_CASE(CommandChangeDuringDecel_HysteresisEffect,
876+
* boost::unit_test::tolerance(1e-3)) {
877+
Context ctx;
878+
879+
constexpr float kAccel = 1000.0f;
880+
constexpr float kInitialTarget = 0.5f;
881+
constexpr float kNewTarget = 0.6f;
882+
883+
ctx.data.position = kInitialTarget;
884+
ctx.data.accel_limit = kAccel;
885+
ctx.data.velocity_limit = NaN;
886+
ctx.rate_hz = 10000.0f;
887+
888+
ctx.set_velocity(0.0f);
889+
ctx.set_position(0.0f);
890+
891+
// Run until mid-deceleration with significant velocity.
892+
int step = 0;
893+
for (; step < 280; step++) {
894+
ctx.Call();
895+
}
896+
897+
BOOST_TEST(ctx.status.control_acceleration < 0.0f);
898+
const float vel_before = ctx.status.control_velocity.value();
899+
BOOST_TEST(vel_before > 5.0f);
900+
901+
// Move target further out.
902+
ctx.data.position = kNewTarget;
903+
ctx.data.position_relative_raw.reset();
904+
905+
// Track steps where we continue decelerating despite target moving out.
906+
int steps_still_decelerating = 0;
907+
908+
for (; step < 10000; step++) {
909+
ctx.Call();
910+
float vel = ctx.status.control_velocity.value();
911+
912+
if (ctx.status.control_acceleration < 0.0f && vel > 0.0f) {
913+
steps_still_decelerating++;
914+
}
915+
916+
if (ctx.status.trajectory_done) {
917+
break;
918+
}
919+
}
920+
921+
// Hysteresis causes continued deceleration for some steps.
922+
BOOST_TEST(steps_still_decelerating > 0);
923+
924+
BOOST_TEST(ctx.from_raw(ctx.status.control_position_raw.value()) == kNewTarget);
925+
BOOST_TEST(ctx.status.trajectory_done == true);
711926
}

0 commit comments

Comments
 (0)