@@ -4826,6 +4826,148 @@ def WheelEncoders(self):
48264826 raise NotAchievedException ("wheel distance incorrect" )
48274827 self .disarm_vehicle ()
48284828
4829+ def MAVLinkWheelEncoders (self ):
4830+ '''test wheel encoders fed by WHEEL_DISTANCE messages'''
4831+ radius = 0.05
4832+ # one encoder on each of four wheels, told apart by position
4833+ wheel_positions = [(0.1 , - 0.1 ), (0.1 , 0.1 ), (- 0.1 , - 0.1 ), (- 0.1 , 0.1 )]
4834+ params = {
4835+ "LOG_DISARMED" : 1 ,
4836+ "LOG_REPLAY" : 1 , # RWOH records each sample sent to EKF3
4837+ }
4838+ for i , (x , y ) in enumerate (wheel_positions ):
4839+ prefix = "WENC" if i == 0 else "WENC%u" % (i + 1 )
4840+ params [prefix + "_TYPE" ] = 2
4841+ params [prefix + "_RADIUS" ] = radius
4842+ params [prefix + "_POS_X" ] = x
4843+ params [prefix + "_POS_Y" ] = y
4844+ self .set_parameters (params )
4845+ self .reboot_sitl ()
4846+
4847+ # the encoder board is a component on this vehicle
4848+ board = mavutil .mavlink_connection (
4849+ self .sitl_serial_endpoint (2 ),
4850+ source_system = self .sysid_thismav (),
4851+ source_component = mavutil .mavlink .MAV_COMP_ID_PERIPHERAL ,
4852+ )
4853+
4854+ def send_wheel_distance (conn , distances ):
4855+ conn .mav .wheel_distance_send (
4856+ int (self .get_sim_time_cached () * 1e6 ),
4857+ len (distances ),
4858+ distances + [0 ] * (16 - len (distances )),
4859+ )
4860+
4861+ # the board sends at 5Hz, slower than the vehicle's 50Hz wheel
4862+ # encoder loop, with all wheels optionally moving at speed
4863+ board_distances = [1.5 , - 2.25 , 3.0 , - 0.75 ]
4864+ speed = 0.5
4865+ motion = {"start" : None , "stop" : None , "paused" : False }
4866+ last_sent = [0 ]
4867+
4868+ def send_from_board (mav , m ):
4869+ now = self .get_sim_time_cached ()
4870+ if motion ["paused" ] or now - last_sent [0 ] < 0.2 :
4871+ return
4872+ last_sent [0 ] = now
4873+ travelled = 0
4874+ if motion ["start" ] is not None :
4875+ travelled = speed * (now - motion ["start" ])
4876+ send_wheel_distance (board , [d + travelled for d in board_distances ])
4877+
4878+ def wait_reported_distances (distances , timeout = 10 ):
4879+ tstart = self .get_sim_time ()
4880+ while self .get_sim_time_cached () - tstart < timeout :
4881+ m = self .assert_receive_message ('WHEEL_DISTANCE' , timeout = 5 )
4882+ if m .get_srcComponent () != mavutil .mavlink .MAV_COMP_ID_AUTOPILOT1 :
4883+ # the board's own messages are routed to us too
4884+ continue
4885+ if (m .count == len (distances ) and
4886+ all (abs (m .distance [i ] - d ) < 0.001 for i , d in enumerate (distances ))):
4887+ return
4888+ raise NotAchievedException ("Vehicle did not report wheel distances %s" % str (distances ))
4889+
4890+ self .context_push ()
4891+ try :
4892+ self .install_message_hook_context (send_from_board )
4893+
4894+ self .start_subtest ("Distances from the vehicle's own encoder board are used" )
4895+ wait_reported_distances (board_distances )
4896+
4897+ self .start_subtest ("Distances from another vehicle are ignored" )
4898+ tstart = self .get_sim_time ()
4899+ while self .get_sim_time_cached () - tstart < 5 :
4900+ # our GCS connection has a different system ID
4901+ send_wheel_distance (self .mav , [99.0 ] * 4 )
4902+ m = self .assert_receive_message ('WHEEL_DISTANCE' , timeout = 5 )
4903+ if (m .get_srcComponent () == mavutil .mavlink .MAV_COMP_ID_AUTOPILOT1 and
4904+ abs (m .distance [0 ] - 99.0 ) < 1 ):
4905+ raise NotAchievedException ("Accepted wheel distance from another system" )
4906+
4907+ self .start_subtest ("Stream wheels moving at %.1fm/s, with a gap in the stream" % speed )
4908+ motion ["start" ] = self .get_sim_time ()
4909+ self .delay_sim_time (5 , reason = "let the wheels turn" )
4910+ motion ["paused" ] = True
4911+ pause_start = self .get_sim_time ()
4912+ self .delay_sim_time (1.5 , reason = "gap in the stream" )
4913+ motion ["paused" ] = False
4914+ pause_stop = self .get_sim_time ()
4915+ self .delay_sim_time (5 , reason = "let the wheels turn" )
4916+ motion ["stop" ] = self .get_sim_time ()
4917+ finally :
4918+ self .context_pop ()
4919+ board .close ()
4920+
4921+ self .start_subtest ("All four wheels are logged and EKF3 is sent the true wheel speed" )
4922+ # ignore samples straddling the start or end of motion
4923+ window_start = motion ["start" ] + 1
4924+ window_stop = motion ["stop" ] - 1
4925+ dfreader = self .dfreader_for_current_onboard_log ()
4926+ logged_stationary_distances = False
4927+ samples = {} # wheel position -> samples sent to EKF3
4928+ while True :
4929+ m = dfreader .recv_match (type = ['WENC' , 'RWOH' ])
4930+ if m is None :
4931+ break
4932+ if m .get_type () == 'WENC' :
4933+ if all (abs (getattr (m , "Dist%u" % i ) - d ) < 0.001 for i , d in enumerate (board_distances )):
4934+ logged_stationary_distances = True
4935+ elif window_start < m .TS * 0.001 < window_stop :
4936+ samples .setdefault ((round (m .PX , 1 ), round (m .PY , 1 )), []).append (m )
4937+ if not logged_stationary_distances :
4938+ raise NotAchievedException ("WENC never logged distances %s" % str (board_distances ))
4939+ if sorted (samples .keys ()) != sorted (wheel_positions ):
4940+ raise NotAchievedException ("Expected samples from all four wheels, got %s" % str (samples .keys ()))
4941+
4942+ expected_coverage = (window_stop - window_start ) - (pause_stop - pause_start )
4943+ for wheel , wheel_samples in samples .items ():
4944+ # the stream's timing is jittery in SITL, so allow a wide
4945+ # margin per sample; stale readings fed as zero rotation, a
4946+ # reading divided by a too-short interval, or one spanning the
4947+ # gap in the stream, fall well outside
4948+ for m in wheel_samples :
4949+ sample_speed = m .DA * m .R / m .DT
4950+ if m .DT > 0.5 or not 0.25 * speed < sample_speed < 2 * speed :
4951+ raise NotAchievedException (
4952+ "Wheel at %s sent EKF3 speed %.3fm/s (DA=%f DT=%f), want %.1fm/s" %
4953+ (str (wheel ), sample_speed , m .DA , m .DT , speed ))
4954+ # readings must resume after the gap
4955+ if not any (m .TS * 0.001 > pause_stop + 1 for m in wheel_samples ):
4956+ raise NotAchievedException ("Wheel at %s not sent to EKF3 after the gap" % str (wheel ))
4957+ # and apart from the gap no readings may be lost
4958+ covered = sum (m .DT for m in wheel_samples )
4959+ travelled = sum (m .DA * m .R for m in wheel_samples )
4960+ if covered < 0.85 * expected_coverage :
4961+ raise NotAchievedException (
4962+ "Wheel at %s samples cover only %.1fs of %.1fs" %
4963+ (str (wheel ), covered , expected_coverage ))
4964+ if abs (travelled / covered - speed ) > 0.05 * speed :
4965+ raise NotAchievedException (
4966+ "Wheel at %s mean speed %.3fm/s, want %.1fm/s" %
4967+ (str (wheel ), travelled / covered , speed ))
4968+ self .progress ("Wheel at %s: %u samples, mean speed %.3fm/s over %.1fs" %
4969+ (str (wheel ), len (wheel_samples ), travelled / covered , covered ))
4970+
48294971 def test_poly_fence_object_avoidance_guided_two_squares (self , target_system = 1 , target_component = 1 ):
48304972 self .start_subtest ("Ensure we can steer around obstacles in guided mode" )
48314973 here = self .get_location ()
@@ -8182,6 +8324,7 @@ def tests(self):
81828324 # self.GCSRally, # enable me in 2026, needs MAVProxy fixes
81838325 self .MotorTest ,
81848326 self .WheelEncoders ,
8327+ self .MAVLinkWheelEncoders ,
81858328 self .DataFlashOverMAVLink ,
81868329 self .DataFlash ,
81878330 self .SkidSteer ,
0 commit comments