@@ -740,3 +740,223 @@ BOOST_AUTO_TEST_CASE(ControlAccelerationConsistent) {
740740 BOOST_TEST (end_of_trajectory_violations <= 15 );
741741 BOOST_TEST (zero_violations == 0 );
742742}
743+
744+ // Test that changing the target position during deceleration works correctly.
745+ // When target is moved further out, we should switch to acceleration.
746+ BOOST_AUTO_TEST_CASE (CommandChangeDuringDecel_TargetFurtherOut,
747+ * boost::unit_test::tolerance (1e-3 )) {
748+ Context ctx;
749+
750+ constexpr float kAccel = 1000 .0f ;
751+ constexpr float kInitialTarget = 0 .5f ;
752+ constexpr float kNewTarget = 1 .0f ; // Further out
753+
754+ ctx.data .position = kInitialTarget ;
755+ ctx.data .accel_limit = kAccel ;
756+ ctx.data .velocity_limit = NaN;
757+ ctx.rate_hz = 10000 .0f ;
758+
759+ ctx.set_velocity (0 .0f );
760+ ctx.set_position (0 .0f );
761+
762+ // For a 0.5 rev trajectory with a=1000:
763+ // - v_peak = sqrt(2*1000*0.25) ≈ 22.4 rev/s
764+ // - t_peak = v_peak/a = 0.0224s = 224 steps at 10kHz
765+ // Run until we're well into deceleration phase
766+ int step = 0 ;
767+ for (; step < 280 ; step++) {
768+ ctx.Call ();
769+ }
770+
771+ // Verify we're decelerating (negative acceleration, positive velocity)
772+ BOOST_TEST (ctx.status .control_acceleration < 0 .0f );
773+ BOOST_TEST (ctx.status .control_velocity .value () > 0 .0f );
774+
775+ // Now move the target further out
776+ ctx.data .position = kNewTarget ;
777+ ctx.data .position_relative_raw .reset (); // Force recalculation
778+
779+ // Continue running - we should eventually switch to acceleration
780+ bool switched_to_accel = false ;
781+ for (; step < 10000 ; step++) {
782+ ctx.Call ();
783+
784+ if (!switched_to_accel && ctx.status .control_acceleration > 0 .0f ) {
785+ switched_to_accel = true ;
786+ }
787+
788+ if (ctx.status .trajectory_done ) {
789+ break ;
790+ }
791+ }
792+
793+ BOOST_TEST (switched_to_accel);
794+ // Should reach the new target
795+ BOOST_TEST (ctx.from_raw (ctx.status .control_position_raw .value ()) == kNewTarget );
796+ BOOST_TEST (ctx.status .trajectory_done == true );
797+ }
798+
799+ // Test that changing the target position closer during deceleration works.
800+ // We should continue decelerating, overshoot, then accelerate back.
801+ BOOST_AUTO_TEST_CASE (CommandChangeDuringDecel_TargetCloser,
802+ * boost::unit_test::tolerance (1e-3 )) {
803+ Context ctx;
804+
805+ constexpr float kAccel = 1000 .0f ;
806+ constexpr float kInitialTarget = 0 .5f ;
807+ constexpr float kNewTarget = 0 .3f ; // Closer
808+
809+ ctx.data .position = kInitialTarget ;
810+ ctx.data .accel_limit = kAccel ;
811+ ctx.data .velocity_limit = NaN;
812+ ctx.rate_hz = 10000 .0f ;
813+
814+ ctx.set_velocity (0 .0f );
815+ ctx.set_position (0 .0f );
816+
817+ // Run until we're well into deceleration phase
818+ int step = 0 ;
819+ for (; step < 280 ; step++) {
820+ ctx.Call ();
821+ }
822+
823+ // Verify we're decelerating
824+ BOOST_TEST (ctx.status .control_acceleration < 0 .0f );
825+ BOOST_TEST (ctx.status .control_velocity .value () > 0 .0f );
826+
827+ // Move the target closer
828+ ctx.data .position = kNewTarget ;
829+ ctx.data .position_relative_raw .reset (); // Force recalculation
830+
831+ // Continue running - we should continue decelerating (hysteresis),
832+ // overshoot the new target, then come back.
833+ bool ever_overshot = false ;
834+
835+ for (; step < 10000 ; step++) {
836+ ctx.Call ();
837+ float pos = ctx.from_raw (ctx.status .control_position_raw .value ());
838+
839+ if (pos > kNewTarget + 0 .001f ) {
840+ ever_overshot = true ;
841+ }
842+
843+ if (ctx.status .trajectory_done ) {
844+ break ;
845+ }
846+ }
847+
848+ // Should overshoot since we were going fast when target moved closer
849+ BOOST_TEST (ever_overshot);
850+ // Should reach the new target
851+ BOOST_TEST (ctx.from_raw (ctx.status .control_position_raw .value ()) == kNewTarget );
852+ BOOST_TEST (ctx.status .trajectory_done == true );
853+ }
854+
855+ // Test that small position changes during deceleration are handled correctly.
856+ // A small increase in target position should still eventually reach it.
857+ BOOST_AUTO_TEST_CASE (CommandChangeDuringDecel_SmallIncrease,
858+ * boost::unit_test::tolerance (1e-3 )) {
859+ Context ctx;
860+
861+ constexpr float kAccel = 1000 .0f ;
862+ constexpr float kInitialTarget = 0 .5f ;
863+ // Small increase - just a few decel distances worth
864+ constexpr float kSmallDelta = 0 .02f ;
865+ constexpr float kNewTarget = kInitialTarget + kSmallDelta ;
866+
867+ ctx.data .position = kInitialTarget ;
868+ ctx.data .accel_limit = kAccel ;
869+ ctx.data .velocity_limit = NaN;
870+ ctx.rate_hz = 10000 .0f ;
871+
872+ ctx.set_velocity (0 .0f );
873+ ctx.set_position (0 .0f );
874+
875+ // Run until we're very late in deceleration phase (low velocity)
876+ // At step 400, we're near the end of a ~448 step trajectory
877+ int step = 0 ;
878+ for (; step < 400 ; step++) {
879+ ctx.Call ();
880+ }
881+
882+ // Verify we're decelerating with relatively low velocity
883+ BOOST_TEST (ctx.status .control_acceleration < 0 .0f );
884+ const float vel_at_change = ctx.status .control_velocity .value ();
885+ BOOST_TEST (vel_at_change > 0 .0f );
886+ BOOST_TEST (vel_at_change < 10 .0f ); // Should be slowing down significantly
887+
888+ // Small increase in target
889+ ctx.data .position = kNewTarget ;
890+ ctx.data .position_relative_raw .reset (); // Force recalculation
891+
892+ // Continue and verify we reach the new target
893+ for (; step < 10000 ; step++) {
894+ ctx.Call ();
895+
896+ if (ctx.status .trajectory_done ) {
897+ break ;
898+ }
899+ }
900+
901+ BOOST_TEST (ctx.from_raw (ctx.status .control_position_raw .value ()) == kNewTarget );
902+ BOOST_TEST (ctx.status .trajectory_done == true );
903+ }
904+
905+ // Test behavior when hysteresis prevents immediate acceleration switch.
906+ // With a position increase during deceleration, the hysteresis causes
907+ // continued deceleration for a number of steps.
908+ BOOST_AUTO_TEST_CASE (CommandChangeDuringDecel_HysteresisEffect,
909+ * boost::unit_test::tolerance (1e-3 )) {
910+ Context ctx;
911+
912+ constexpr float kAccel = 1000 .0f ;
913+ constexpr float kInitialTarget = 0 .5f ;
914+ constexpr float kNewTarget = 0 .6f ; // 0.1 rev further
915+
916+ ctx.data .position = kInitialTarget ;
917+ ctx.data .accel_limit = kAccel ;
918+ ctx.data .velocity_limit = NaN;
919+ ctx.rate_hz = 10000 .0f ;
920+
921+ ctx.set_velocity (0 .0f );
922+ ctx.set_position (0 .0f );
923+
924+ // Run until we're decelerating but still have significant velocity
925+ // At step 280, we're in the middle of deceleration
926+ int step = 0 ;
927+ for (; step < 280 ; step++) {
928+ ctx.Call ();
929+ }
930+
931+ BOOST_TEST (ctx.status .control_acceleration < 0 .0f );
932+ const float vel_before = ctx.status .control_velocity .value ();
933+ BOOST_TEST (vel_before > 5 .0f ); // Still significant velocity
934+
935+ // Move target further out
936+ ctx.data .position = kNewTarget ;
937+ ctx.data .position_relative_raw .reset (); // Force recalculation
938+
939+ // Track acceleration changes after the command change
940+ int steps_still_decelerating = 0 ;
941+
942+ for (; step < 10000 ; step++) {
943+ ctx.Call ();
944+ float vel = ctx.status .control_velocity .value ();
945+
946+ if (ctx.status .control_acceleration < 0 .0f && vel > 0 .0f ) {
947+ steps_still_decelerating++;
948+ }
949+
950+ if (ctx.status .trajectory_done ) {
951+ break ;
952+ }
953+ }
954+
955+ // Due to hysteresis, we should continue decelerating for some steps
956+ // even though we could theoretically switch to acceleration immediately.
957+ BOOST_TEST (steps_still_decelerating > 0 );
958+
959+ // Should still reach the new target
960+ BOOST_TEST (ctx.from_raw (ctx.status .control_position_raw .value ()) == kNewTarget );
961+ BOOST_TEST (ctx.status .trajectory_done == true );
962+ }
0 commit comments