Skip to content

Commit 2a66a55

Browse files
committed
dotbot/dotbot_simulator: rework simulator threads and wheel speed model
1 parent 69f7759 commit 2a66a55

1 file changed

Lines changed: 147 additions & 93 deletions

File tree

dotbot/dotbot_simulator.py

Lines changed: 147 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,21 @@
2323
from dotbot.logger import LOGGER
2424
from 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
2833
MOTOR_SPEED = 60
34+
ANGULAR_SPEED_GAIN = 1.2
35+
REDUCE_SPEED_FACTOR = 0.7
36+
REDUCE_SPEED_ANGLE = 25
37+
2938
SIMULATOR_STEP_DELTA_T = 0.02 # 20 ms
3039

40+
# Battery model parameters
3141
INITIAL_BATTERY_VOLTAGE = 3000 # mV
3242
MAX_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+
4865
class 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

Comments
 (0)