Skip to content

Commit 3d80454

Browse files
Move kinematics to a module.
Signed-off-by: Franco Cipollone <franco.c@ekumenlabs.com>
1 parent 77cac4a commit 3d80454

4 files changed

Lines changed: 238 additions & 107 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import numpy as np
2+
3+
class LeKiwiMobileBase:
4+
"""Class representing the mobile base of the LeKiwi robot.
5+
The mobile base holds the kinematics of a 3-wheeled omnidirectional robot.
6+
Where the wheels are placed at 120 degrees from each other.
7+
8+
The wheel configuration is assumed to be:
9+
- Wheel 1 is at the "left", forming 30 degrees to the forward direction.
10+
- Wheel 2 is at the "right" clockwise 120 degrees from Wheel 1.
11+
- Wheel 3 is at the "back", clockwise 240 degrees from Wheel 1.
12+
13+
"""
14+
15+
def __init__(self, wheel_radius: float, robot_base_radius: float) -> None:
16+
"""
17+
Initialize the LeKiwiMobileBase.
18+
19+
Args:
20+
wheel_radius (float): The radius of the wheels in meters.
21+
robot_base_radius (float): The distance from the center of rotation to each wheel in meters.
22+
"""
23+
self.wheel_radius = wheel_radius
24+
self.robot_base_radius = robot_base_radius
25+
26+
self.F_matrix = self._compute_forward_kinematics_matrix(self.wheel_radius, self.robot_base_radius)
27+
self.F_matrix_inv = np.linalg.inv(self.F_matrix)
28+
29+
def forward_kinematics(self, wheel_speeds: np.ndarray) -> np.ndarray:
30+
"""Compute the forward kinematics of the mobile base.
31+
32+
Args:
33+
wheel_speeds (np.ndarray): Angular velocities of the wheels [left, right, back] in rad/s.
34+
35+
Returns:
36+
np.ndarray: An array representing the robot's velocity [vx, vy, omega] in m/s and rad/s.
37+
"""
38+
if wheel_speeds.shape != (3,):
39+
raise ValueError("wheel_speeds must be a numpy array of shape (3,)")
40+
41+
# Compute the robot's velocity in the body frame
42+
robot_velocity = self.F_matrix @ wheel_speeds
43+
return robot_velocity
44+
45+
def inverse_kinematics(self, robot_velocity: np.ndarray) -> np.ndarray:
46+
"""Compute the inverse kinematics of the mobile base.
47+
48+
Args:
49+
robot_velocity (np.ndarray): The desired robot velocity [vx, vy, omega] in m/s and rad/s.
50+
51+
Returns:
52+
np.ndarray: An array representing the angular velocities of the wheels [left, right, back] in rad/s.
53+
"""
54+
if robot_velocity.shape != (3,):
55+
raise ValueError("robot_velocity must be a numpy array of shape (3,)")
56+
57+
# Compute the wheel speeds required to achieve the desired robot velocity
58+
wheel_speeds = self.F_matrix_inv @ robot_velocity
59+
return wheel_speeds
60+
61+
@staticmethod
62+
def _compute_forward_kinematics_matrix(r, L) -> np.ndarray:
63+
"""
64+
Calculates the forward kinematics transformation matrix for a 3-wheeled omni-drive robot.
65+
This matrix maps wheel speeds(angular velocities) to robot body speeds(linear and angular velocities) following the conventions:
66+
- The robot's forward direction is along the positive x-axis.
67+
- The robot's left direction is along the positive y-axis.
68+
- The robot's up direction is along the positive z-axis.
69+
- The robot's angular velocity (omega) is positive for counter-clockwise rotation when viewed from above.
70+
71+
Args:
72+
r (float): The radius of each wheel (in meters).
73+
L (float): The radius of the robot base, from center to wheel (in meters).
74+
75+
Returns:
76+
np.ndarray: A 3x3 transformation matrix that maps wheel speeds to robot body speeds.
77+
"""
78+
F_matrix = r * np.array([[np.sqrt(3)/2, -np.sqrt(3)/2, 0],
79+
[-1/2, -1/2, 1],
80+
[-1/(3*L), -1/(3*L), -1/(3*L)]])
81+
return F_matrix

packages/lekiwi_sim/lekiwi_sim/lekiwi_sim_host.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# See the License for the specific language governing permissions and
1717
# limitations under the License.
1818

19+
import argparse
1920
import json
2021
import logging
2122
import time
@@ -54,6 +55,24 @@ def disconnect(self) -> None:
5455

5556
def main() -> None:
5657
"""Main function to run the LeKiwi simulation host."""
58+
59+
parser = argparse.ArgumentParser(
60+
description="Run the LeKiwi simulation host to be accessed via lerobot.robot.LekiwiClient."
61+
)
62+
63+
parser.add_argument(
64+
'-l', '--level',
65+
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
66+
default='INFO',
67+
help='Set the logging level (default: INFO). Case-insensitive.'
68+
)
69+
args = parser.parse_args()
70+
log_level = args.level.upper()
71+
logging.basicConfig(
72+
level=log_level,
73+
format='%(asctime)s | %(levelname)-8s | %(message)s',
74+
datefmt='%Y-%m-%d %H:%M:%S'
75+
)
5776
logging.info("Configuring LeKiwi")
5877
robot_config = LeKiwiMujocoConfig()
5978
robot = LeKiwiMujoco(robot_config)
@@ -77,7 +96,7 @@ def main() -> None:
7796
try:
7897
msg = host.zmq_cmd_socket.recv_string(zmq.NOBLOCK)
7998
data = dict(json.loads(msg))
80-
print("Received command:", data)
99+
logging.debug("Received command: %s", data)
81100
_action_sent = robot.send_action(data)
82101
last_cmd_time = time.time()
83102
watchdog_active = False
@@ -111,7 +130,7 @@ def main() -> None:
111130
try:
112131
host.zmq_observation_socket.send_string(json.dumps(last_observation), flags=zmq.NOBLOCK)
113132
except zmq.Again:
114-
logging.info("Dropping observation, no client connected")
133+
logging.debug("Dropping observation, no client connected")
115134

116135
# Ensure a short sleep to avoid overloading the CPU.
117136
elapsed = time.time() - loop_start_time

packages/lekiwi_sim/lekiwi_sim/robot.py

Lines changed: 28 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"""LeRobot Robot implementation for MuJoCo simulation with LeKiwi robot"""
22

3-
from dataclasses import dataclass
3+
import logging
44
import threading
5+
from dataclasses import dataclass
56
from typing import Any
67

78
import mujoco
89
import mujoco.viewer
910
import numpy as np
1011
from lerobot.robots.robot import Robot
1112

13+
from .kinematics import LeKiwiMobileBase
1214
from .utilities import get_scene_path, get_timestep_config
1315

1416

@@ -120,6 +122,7 @@ def __init__(self, config: LeKiwiMujocoConfig) -> None:
120122
self.mj_data = mujoco.MjData(self.mj_model)
121123
self.simulation_thread = threading.Thread(target=self.run_mujoco_loop, daemon=True)
122124
self.mujoco_is_running = False
125+
self.mobile_base_kinematics = LeKiwiMobileBase(wheel_radius=0.05, robot_base_radius=0.125)
123126

124127
def run_mujoco_loop(self) -> None:
125128
"""Run the MuJoCo simulation loop in a separate thread."""
@@ -138,12 +141,18 @@ def run_mujoco_loop(self) -> None:
138141
if joint_name.startswith("arm_"):
139142
arm_state[f"{joint_name}.pos"] = self.mj_data.joint(joint_name).qpos[0]
140143

141-
wheel_state = self._wheel_rads_to_body(
142-
self.mj_data.joint("base_left_wheel_joint").qvel[0],
143-
self.mj_data.joint("base_back_wheel_joint").qvel[0],
144-
self.mj_data.joint("base_right_wheel_joint").qvel[0],
144+
mobile_base_joint_velocities = [self.mj_data.joint("base_left_wheel_joint").qvel[0],
145+
self.mj_data.joint("base_right_wheel_joint").qvel[0],
146+
self.mj_data.joint("base_back_wheel_joint").qvel[0]]
147+
mobile_base_velocity = self.mobile_base_kinematics.forward_kinematics(
148+
np.array(mobile_base_joint_velocities)
145149
)
146-
150+
wheel_state = {
151+
"x.vel": mobile_base_velocity[0],
152+
"y.vel": mobile_base_velocity[1],
153+
"theta.vel": np.degrees(mobile_base_velocity[2]),
154+
}
155+
147156
self.protected_observation.set_observation({**arm_state, **wheel_state})
148157

149158
viewer.sync()
@@ -259,21 +268,26 @@ def send_action(self, action: dict[str, Any]) -> dict[str, Any]:
259268
safety limits on velocity.
260269
261270
"""
262-
print("Action received:", action)
271+
logging.debug("Action received: %s", action)
263272
base_goal_vel = {k: v for k, v in action.items() if k.endswith(".vel")}
264273

265-
base_wheel_goal_vel = self._body_to_wheel_rads(
266-
base_goal_vel["x.vel"], base_goal_vel["y.vel"], base_goal_vel["theta.vel"]
274+
275+
base_wheel_goal_vel = self.mobile_base_kinematics.inverse_kinematics(
276+
np.array([base_goal_vel.get("x.vel", 0.0),
277+
base_goal_vel.get("y.vel", 0.0),
278+
np.radians(base_goal_vel.get("theta.vel", 0.0))])
267279
)
268280

269281
self.protected_lekiwi_data.set_base_data(
270-
base_wheel_goal_vel["base_left_wheel"],
271-
base_wheel_goal_vel["base_back_wheel"],
272-
base_wheel_goal_vel["base_right_wheel"],
282+
base_left_wheel_vel=base_wheel_goal_vel[0],
283+
base_right_wheel_vel=base_wheel_goal_vel[1],
284+
base_back_wheel_vel=base_wheel_goal_vel[2],
273285
)
274-
print("Wheels vel:", base_wheel_goal_vel)
286+
logging.debug("Set wheel velocities to: %s", base_wheel_goal_vel)
275287

276-
return base_wheel_goal_vel
288+
return {"base_left_wheel_vel": base_wheel_goal_vel[0],
289+
"base_right_wheel_vel": base_wheel_goal_vel[1],
290+
"base_back_wheel_vel": base_wheel_goal_vel[2]}
277291

278292
def disconnect(self) -> None:
279293
"""Disconnect from the robot and perform any necessary cleanup."""
@@ -285,94 +299,3 @@ def stop_base(self) -> None:
285299
"""Stop the robot's base movement immediately."""
286300
# TODO(arilow): Implement.
287301
return
288-
289-
# TODO(https://github.com/ekumenlabs/lekiwi-dora/pull/11#discussion_r2310632598): Move this
290-
# to a kinematics module.
291-
def _body_to_wheel_rads(
292-
self,
293-
x: float,
294-
y: float,
295-
theta: float,
296-
wheel_radius: float = 0.05,
297-
base_radius: float = 0.125,
298-
max_raw: int = 3000,
299-
) -> dict[str, float]:
300-
"""Convert desired body-frame velocities into wheel raw commands.
301-
302-
Args:
303-
x : Linear velocity in x (m/s).
304-
y : Linear velocity in y (m/s).
305-
theta : Rotational velocity (deg/s).
306-
wheel_radius: Radius of each wheel (meters).
307-
base_radius : Distance from the center of rotation to each wheel (meters).
308-
max_raw : Maximum allowed raw command (ticks) per wheel.
309-
310-
Returns:
311-
A dictionary with wheels angular speeds in rad/s as:
312-
{"base_left_wheel": value, "base_back_wheel": value, "base_right_wheel": value}.
313-
314-
"""
315-
# Convert rotational velocity from deg/s to rad/s.
316-
theta_rad = theta * (np.pi / 180.0)
317-
318-
# Create the body velocity vector [x, y, theta_rad].
319-
velocity_vector = np.array([x, y, theta_rad])
320-
321-
# Define the wheel mounting angles with a -90° offset.
322-
angles = np.radians(np.array([240, 0, 120]) - 90)
323-
# Build the kinematic matrix: each row maps body velocities to a wheel's linear speed.
324-
# The third column (base_radius) accounts for the effect of rotation.
325-
m = np.array([[np.cos(a), np.sin(a), base_radius] for a in angles])
326-
327-
# Compute each wheel's linear speed (m/s) and then its angular speed (rad/s).
328-
wheel_linear_speeds = m.dot(velocity_vector)
329-
wheel_angular_speeds = wheel_linear_speeds / wheel_radius
330-
331-
# TODO(arilow): Find out why the wheels need to be inverted.
332-
corrected_wheel_angular_speeds = -wheel_angular_speeds
333-
return {
334-
"base_left_wheel": corrected_wheel_angular_speeds[0],
335-
"base_back_wheel": corrected_wheel_angular_speeds[1],
336-
"base_right_wheel": corrected_wheel_angular_speeds[2],
337-
}
338-
339-
def _wheel_rads_to_body(
340-
self,
341-
left_wheel_speed: float,
342-
back_wheel_speed: float,
343-
right_wheel_speed: float,
344-
wheel_radius: float = 0.05,
345-
base_radius: float = 0.125,
346-
) -> dict[str, Any]:
347-
"""Convert wheel raw command feedback back into body-frame velocities.
348-
349-
Args:
350-
left_wheel_speed : Left wheel velocity in rad/s
351-
back_wheel_speed : Back wheel velocity in rad/s
352-
right_wheel_speed : Right wheel velocity in rad/s
353-
wheel_radius: Radius of each wheel (meters).
354-
base_radius : Distance from the robot center to each wheel (meters).
355-
356-
Returns:
357-
A dict (x.vel, y.vel, theta.vel) all in m/s
358-
359-
"""
360-
wheel_radps = np.array([left_wheel_speed, back_wheel_speed, right_wheel_speed])
361-
# Compute each wheel's linear speed (m/s) from its angular speed.
362-
wheel_linear_speeds = wheel_radps * wheel_radius
363-
364-
# Define the wheel mounting angles with a -90° offset.
365-
angles = np.radians(np.array([240, 0, 120]) - 90)
366-
# TODO(https://github.com/ekumenlabs/lekiwi-dora/pull/11#discussion_r2310641980): Review kinematics here.
367-
m = np.array([[np.cos(a), np.sin(a), base_radius] for a in angles])
368-
369-
# Solve the inverse kinematics: body_velocity = M⁻¹ · wheel_linear_speeds.
370-
m_inv = np.linalg.inv(m)
371-
velocity_vector = m_inv.dot(wheel_linear_speeds)
372-
x, y, theta_rad = velocity_vector
373-
theta = theta_rad * (180.0 / np.pi)
374-
return {
375-
"x.vel": x,
376-
"y.vel": y,
377-
"theta.vel": theta,
378-
} # m/s and deg/s

0 commit comments

Comments
 (0)