@@ -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