Skip to content

Commit 3be70b3

Browse files
authored
Merge pull request #219 from aabadie/refactor_simulator
dotbot/dotbot_simulator: rework simulator threads and wheel speed model
2 parents 733c313 + a22c58e commit 3be70b3

1 file changed

Lines changed: 150 additions & 93 deletions

File tree

dotbot/dotbot_simulator.py

Lines changed: 150 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 abs(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,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

Comments
 (0)