2323from dotbot .logger import LOGGER
2424from dotbot .protocol import PayloadDotBotAdvertisement , PayloadType
2525
26- R = 25
27- L = 70
26+ Kv = 400 # motor speed constant in RPM
27+ r = 50 # motor reduction ratio
28+ R = 25 # wheel radius in mm
29+ L = 70 # distance between the two wheels in mm
30+ MIN_PWM_TO_MOVE = 30 # minimum PWM value to overcome static friction and start moving
31+
32+ # Control parameters for the automatic mode
2833MOTOR_SPEED = 60
34+ ANGULAR_SPEED_GAIN = 1.2
35+ REDUCE_SPEED_FACTOR = 0.7
36+ REDUCE_SPEED_ANGLE = 25
37+
2938SIMULATOR_STEP_DELTA_T = 0.02 # 20 ms
3039
40+ # Battery model parameters
3141INITIAL_BATTERY_VOLTAGE = 3000 # mV
3242MAX_BATTERY_DURATION = 300 # 5 minutes
3343
@@ -45,6 +55,13 @@ def battery_discharge_model(time_elapsed_s: float) -> int:
4555 return voltage
4656
4757
58+ def wheel_speed_from_pwm (pwm : float ) -> float :
59+ """Convert a PWM value to a wheel speed in mm/s."""
60+ if pwm < MIN_PWM_TO_MOVE :
61+ return 0.0
62+ return pwm * R * Kv / (r * 127 )
63+
64+
4865class DotBotSimulatorMode (Enum ):
4966 """Operation mode of the dotbot simulator."""
5067
@@ -91,8 +108,8 @@ def __init__(self, settings: SimulatedDotBotSettings, tx_queue: queue.Queue):
91108 self .motor_right_error = settings .motor_right_error
92109 self .time_elapsed_s = 0
93110
94- self .v_left = 0
95- self .v_right = 0
111+ self .pwm_left = 0
112+ self .pwm_right = 0
96113
97114 self .calibrated = settings .calibrated
98115 self .waypoint_threshold = 0
@@ -103,13 +120,15 @@ def __init__(self, settings: SimulatedDotBotSettings, tx_queue: queue.Queue):
103120 self .tx_queue = tx_queue
104121 self .queue = queue .Queue ()
105122 self .advertise_thread = threading .Thread (target = self .advertise , daemon = True )
123+ self .control_thread = threading .Thread (target = self .control_thread , daemon = True )
106124 self .rx_thread = threading .Thread (target = self .rx_frame , daemon = True )
107- self .main_thread = threading .Thread (target = self .run , daemon = True )
125+ self .main_thread = threading .Thread (target = self .update_state , daemon = True )
108126 self .controller_mode : DotBotSimulatorMode = DotBotSimulatorMode .MANUAL
109127 self .logger = LOGGER .bind (context = __name__ , address = self .address )
110128 self ._stop_event = threading .Event ()
111129 self .rx_thread .start ()
112130 self .advertise_thread .start ()
131+ self .control_thread .start ()
113132 self .main_thread .start ()
114133
115134 @property
@@ -119,86 +138,119 @@ def header(self):
119138 source = int (self .address , 16 ),
120139 )
121140
122- def _diff_drive_bot (self , x_pos_old , y_pos_old , theta_old , v_right , v_left ):
123- """Execute state space model of a rigid differential drive robot."""
124- v_right_real = v_right * (1 - self .motor_right_error )
125- v_left_real = v_left * (1 - self .motor_left_error )
126- w = R * (v_left_real - v_right_real ) / L
127- V = R * (v_left_real + v_right_real ) / 2
128- x_dot = V * cos (theta_old - pi )
129- y_dot = V * sin (theta_old - pi )
130-
131- x_pos = x_pos_old + x_dot * SIMULATOR_STEP_DELTA_T
132- y_pos = y_pos_old + y_dot * SIMULATOR_STEP_DELTA_T
133- theta = (theta_old + w * SIMULATOR_STEP_DELTA_T ) % (2 * pi )
134-
135- return x_pos , y_pos , theta
136-
137- def update (self , dt : float ):
141+ def _diff_drive_model_update (self , dt : float ):
138142 """State space model update."""
139143 pos_x_old = self .pos_x
140144 pos_y_old = self .pos_y
141145 theta_old = self .theta
142146
143- if self .controller_mode == DotBotSimulatorMode .MANUAL :
144- self .pos_x , self .pos_y , self .theta = self ._diff_drive_bot (
145- pos_x_old , pos_y_old , theta_old , self .v_right , self .v_left
146- )
147- elif self .controller_mode == DotBotSimulatorMode .AUTOMATIC :
148- delta_x = self .pos_x - self .waypoints [self .waypoint_index ].pos_x
149- delta_y = self .pos_y - self .waypoints [self .waypoint_index ].pos_y
150- distance_to_target = sqrt (delta_x ** 2 + delta_y ** 2 )
151-
152- # check if we are close enough to the "next" waypoint
153- if distance_to_target < self .waypoint_threshold :
154- self .logger .info ("Waypoint reached" , waypoint_index = self .waypoint_index )
155- self .waypoint_index += 1
156- # check if there are no more waypoints:
157- if self .waypoint_index >= len (self .waypoints ):
158- self .logger .info (
159- "Last waypoint reached" , waypoint_index = self .waypoint_index
160- )
161- self .v_left = 0
162- self .v_right = 0
163- self .waypoint_index = 0
164- self .controller_mode = DotBotSimulatorMode .MANUAL
165- else :
166- robot_angle = self .theta
167- angle_to_target = atan2 (delta_y , delta_x )
168- if robot_angle >= pi :
169- robot_angle = robot_angle - 2 * pi
170- # if (angle_to_target < 0):
171- # angle_to_target = 2*pi + angle_to_target
172-
173- error_angle = ((angle_to_target - robot_angle + pi ) % (2 * pi )) - pi
174- self .logger .debug (
175- "Moving to waypoint" ,
176- robot_angle = robot_angle ,
177- angle_to_target = angle_to_target ,
178- error_angle = error_angle ,
179- )
147+ if self .pwm_left > 100 :
148+ self .pwm_left = 100
149+ if self .pwm_right > 100 :
150+ self .pwm_right = 100
151+ if self .pwm_left < 0 :
152+ self .pwm_left = 0
153+ if self .pwm_right < 0 :
154+ self .pwm_right = 0
155+
156+ # Compute each wheel's real speed considering the motor error and the minimum PWM to move
157+ v_left_real = wheel_speed_from_pwm (self .pwm_left ) * (1 - self .motor_left_error )
158+ v_right_real = wheel_speed_from_pwm (self .pwm_right ) * (
159+ 1 - self .motor_right_error
160+ )
161+
162+ V = (v_left_real + v_right_real ) / 2
163+ w = (v_left_real - v_right_real ) / L
164+ x_dot = V * cos (theta_old - pi )
165+ y_dot = V * sin (theta_old - pi )
166+
167+ self .pos_x = pos_x_old + x_dot * SIMULATOR_STEP_DELTA_T
168+ self .pos_y = pos_y_old + y_dot * SIMULATOR_STEP_DELTA_T
169+ self .theta = (theta_old + w * SIMULATOR_STEP_DELTA_T ) % (2 * pi )
180170
181- angular_speed = error_angle * MOTOR_SPEED
182- self .v_left = MOTOR_SPEED + angular_speed
183- self .v_right = MOTOR_SPEED - angular_speed
184-
185- if self .v_left > 100 :
186- self .v_left = 100
187- if self .v_right > 100 :
188- self .v_right = 100
189- if self .v_left < 0 :
190- self .v_left = 0
191- if self .v_right < 0 :
192- self .v_right = 0
193-
194- self .pos_x , self .pos_y , self .theta = self ._diff_drive_bot (
195- self .pos_x , self .pos_y , self .theta , self .v_right , self .v_left
196- )
197171 self .logger .debug (
198- "DotBot simulator update" , x = self .pos_x , y = self .pos_y , theta = self .theta
172+ "State updated" ,
173+ pos_x = self .pos_x ,
174+ pos_y = self .pos_y ,
175+ theta = self .theta ,
176+ pwm_left = self .pwm_left ,
177+ pwm_right = self .pwm_right ,
178+ v_left_real = v_left_real ,
179+ v_right_real = v_right_real ,
199180 )
200181 self .time_elapsed_s += dt
201182
183+ def update_state (self ):
184+ """Update the state of the dotbot simulator."""
185+ while True :
186+ with self ._lock :
187+ self ._diff_drive_model_update (SIMULATOR_STEP_DELTA_T )
188+ is_stopped = self ._stop_event .wait (SIMULATOR_STEP_DELTA_T )
189+ if is_stopped :
190+ break
191+
192+ def _compute_automatic_control (self ):
193+ if self .controller_mode != DotBotSimulatorMode .AUTOMATIC :
194+ return
195+
196+ delta_x = self .pos_x - self .waypoints [self .waypoint_index ].pos_x
197+ delta_y = self .pos_y - self .waypoints [self .waypoint_index ].pos_y
198+ distance_to_target = sqrt (delta_x ** 2 + delta_y ** 2 )
199+
200+ # check if we are close enough to the "next" waypoint
201+ if distance_to_target < self .waypoint_threshold :
202+ self .logger .info ("Waypoint reached" , waypoint_index = self .waypoint_index )
203+ self .waypoint_index += 1
204+ # check if there are no more waypoints:
205+ if self .waypoint_index >= len (self .waypoints ):
206+ self .logger .info (
207+ "Last waypoint reached" , waypoint_index = self .waypoint_index
208+ )
209+ self .pwm_left = 0
210+ self .pwm_right = 0
211+ self .waypoint_index = 0
212+ self .controller_mode = DotBotSimulatorMode .MANUAL
213+ else :
214+ robot_angle = self .theta
215+ angle_to_target = atan2 (delta_y , delta_x )
216+ if robot_angle >= pi :
217+ robot_angle = robot_angle - 2 * pi
218+
219+ error_angle = ((angle_to_target - robot_angle + pi ) % (2 * pi )) - pi
220+ self .logger .info (
221+ "Moving to waypoint" ,
222+ robot_angle = robot_angle ,
223+ angle_to_target = angle_to_target ,
224+ error_angle = error_angle ,
225+ )
226+
227+ speed_reduction_factor : float = 1.0
228+ if distance_to_target < self .waypoint_threshold * 2 :
229+ speed_reduction_factor = REDUCE_SPEED_FACTOR
230+
231+ if error_angle > REDUCE_SPEED_ANGLE or error_angle < - REDUCE_SPEED_ANGLE :
232+ speed_reduction_factor = REDUCE_SPEED_FACTOR
233+
234+ angular_speed = error_angle * MOTOR_SPEED * ANGULAR_SPEED_GAIN
235+ self .pwm_left = MOTOR_SPEED * speed_reduction_factor + angular_speed
236+ self .pwm_right = MOTOR_SPEED * speed_reduction_factor - angular_speed
237+
238+ self .logger .info (
239+ "Control loop update" ,
240+ v_left = self .pwm_left ,
241+ v_right = self .pwm_right ,
242+ theta = self .theta ,
243+ )
244+
245+ def control_thread (self ):
246+ """Control thread to update the state of the dotbot simulator."""
247+ while self ._stop_event .is_set () is False :
248+ with self ._lock :
249+ self ._compute_automatic_control ()
250+ is_stopped = self ._stop_event .wait (SIMULATOR_UPDATE_INTERVAL_S )
251+ if is_stopped :
252+ break
253+
202254 def advertise (self ):
203255 """Send an advertisement message to the gateway."""
204256 while self ._stop_event .is_set () is False :
@@ -231,37 +283,39 @@ def rx_frame(self):
231283 if self .address == hex (frame .header .destination )[2 :]:
232284 if frame .payload_type == PayloadType .CMD_MOVE_RAW :
233285 self .controller_mode = DotBotSimulatorMode .MANUAL
234- self .v_left = frame .packet .payload .left_y
235- self .v_right = frame .packet .payload .right_y
236-
237- if self .v_left > 127 :
238- self .v_left = self .v_left - 256
239- if self .v_right > 127 :
240- self .v_right = self .v_right - 256
286+ self .pwm_left = frame .packet .payload .left_y
287+ self .pwm_right = frame .packet .payload .right_y
288+ self .logger .info (
289+ "RAW command received" ,
290+ v_left = self .pwm_left ,
291+ v_right = self .pwm_right ,
292+ )
293+
294+ if self .pwm_left > 127 :
295+ self .pwm_left = self .pwm_left - 256
296+ if self .pwm_right > 127 :
297+ self .pwm_right = self .pwm_right - 256
241298
242299 elif frame .payload_type == PayloadType .LH2_WAYPOINTS :
243- self .v_left = 0
244- self .v_right = 0
300+ self .pwm_left = 0
301+ self .pwm_right = 0
245302 self .controller_mode = DotBotSimulatorMode .MANUAL
246303 self .waypoint_threshold = frame .packet .payload .threshold
247304 self .waypoints = frame .packet .payload .waypoints
305+ self .logger .info (
306+ "Waypoints received" ,
307+ threshold = self .waypoint_threshold ,
308+ waypoints = self .waypoints ,
309+ )
248310 if self .waypoints :
249311 self .controller_mode = DotBotSimulatorMode .AUTOMATIC
250312
251- def run (self ):
252- """Update the state of the dotbot simulator."""
253- while True :
254- with self ._lock :
255- self .update (SIMULATOR_UPDATE_INTERVAL_S )
256- is_stopped = self ._stop_event .wait (SIMULATOR_UPDATE_INTERVAL_S )
257- if is_stopped :
258- break
259-
260313 def stop (self ):
261314 self .logger .info (f"Stopping DotBot { self .address } simulator..." )
262315 self ._stop_event .set ()
263316 self .queue .put_nowait (None ) # unblock the rx_thread if waiting on the queue
264317 self .advertise_thread .join ()
318+ self .control_thread .join ()
265319 self .rx_thread .join ()
266320 self .main_thread .join ()
267321
0 commit comments