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 abs (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,85 +138,121 @@ 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
141+ def _diff_drive_model_update (self ):
142+ """State space model update."""
143+ pos_x_old = self .pos_x
144+ pos_y_old = self .pos_y
145+ theta_old = self .theta
146+
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 < - 100 :
152+ self .pwm_left = - 100
153+ if self .pwm_right < - 100 :
154+ self .pwm_right = - 100
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
128164 x_dot = V * cos (theta_old - pi )
129165 y_dot = V * sin (theta_old - pi )
130166
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 )
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 )
134170
135- return x_pos , y_pos , theta
171+ self .logger .debug (
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 ,
180+ )
181+ self .time_elapsed_s += SIMULATOR_STEP_DELTA_T
136182
137- def update (self , dt : float ):
138- """State space model update."""
139- pos_x_old = self .pos_x
140- pos_y_old = self .pos_y
141- theta_old = self .theta
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 ()
188+ is_stopped = self ._stop_event .wait (SIMULATOR_STEP_DELTA_T )
189+ if is_stopped :
190+ break
142191
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- )
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 )
180199
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
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 ,
196225 )
197- self .logger .debug (
198- "DotBot simulator update" , x = self .pos_x , y = self .pos_y , theta = self .theta
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+ error_angle_degrees = error_angle * 180 / pi
232+ if (
233+ error_angle_degrees > REDUCE_SPEED_ANGLE
234+ or error_angle_degrees < - REDUCE_SPEED_ANGLE
235+ ):
236+ speed_reduction_factor = REDUCE_SPEED_FACTOR
237+ angular_speed = error_angle * MOTOR_SPEED * ANGULAR_SPEED_GAIN
238+ self .pwm_left = MOTOR_SPEED * speed_reduction_factor + angular_speed
239+ self .pwm_right = MOTOR_SPEED * speed_reduction_factor - angular_speed
240+
241+ self .logger .info (
242+ "Control loop update" ,
243+ v_left = self .pwm_left ,
244+ v_right = self .pwm_right ,
245+ theta = self .theta ,
199246 )
200- self .time_elapsed_s += dt
247+
248+ def control_thread (self ):
249+ """Control thread to update the state of the dotbot simulator."""
250+ while self ._stop_event .is_set () is False :
251+ with self ._lock :
252+ self ._compute_automatic_control ()
253+ is_stopped = self ._stop_event .wait (SIMULATOR_UPDATE_INTERVAL_S )
254+ if is_stopped :
255+ break
201256
202257 def advertise (self ):
203258 """Send an advertisement message to the gateway."""
@@ -231,37 +286,39 @@ def rx_frame(self):
231286 if self .address == hex (frame .header .destination )[2 :]:
232287 if frame .payload_type == PayloadType .CMD_MOVE_RAW :
233288 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
289+ self .pwm_left = frame .packet .payload .left_y
290+ self .pwm_right = frame .packet .payload .right_y
291+ self .logger .info (
292+ "RAW command received" ,
293+ v_left = self .pwm_left ,
294+ v_right = self .pwm_right ,
295+ )
296+
297+ if self .pwm_left > 127 :
298+ self .pwm_left = self .pwm_left - 256
299+ if self .pwm_right > 127 :
300+ self .pwm_right = self .pwm_right - 256
241301
242302 elif frame .payload_type == PayloadType .LH2_WAYPOINTS :
243- self .v_left = 0
244- self .v_right = 0
303+ self .pwm_left = 0
304+ self .pwm_right = 0
245305 self .controller_mode = DotBotSimulatorMode .MANUAL
246306 self .waypoint_threshold = frame .packet .payload .threshold
247307 self .waypoints = frame .packet .payload .waypoints
308+ self .logger .info (
309+ "Waypoints received" ,
310+ threshold = self .waypoint_threshold ,
311+ waypoints = self .waypoints ,
312+ )
248313 if self .waypoints :
249314 self .controller_mode = DotBotSimulatorMode .AUTOMATIC
250315
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-
260316 def stop (self ):
261317 self .logger .info (f"Stopping DotBot { self .address } simulator..." )
262318 self ._stop_event .set ()
263319 self .queue .put_nowait (None ) # unblock the rx_thread if waiting on the queue
264320 self .advertise_thread .join ()
321+ self .control_thread .join ()
265322 self .rx_thread .join ()
266323 self .main_thread .join ()
267324
0 commit comments