Skip to content

Commit e4e579a

Browse files
Adds basic architecture for the Lekiwi robot simulation in MuJoCo. (#11)
Signed-off-by: Ari Lowy <arilow@ekumenlabs.com> Signed-off-by: Franco Cipollone <franco.c@ekumenlabs.com> Co-authored-by: Franco Cipollone <franco.c@ekumenlabs.com>
1 parent 20eabe3 commit e4e579a

16 files changed

Lines changed: 5531 additions & 167 deletions

File tree

packages/lekiwi_sim/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ Create environment: `uv venv -p 3.11 --seed`
22

33
Install: `uv pip install -e .`
44

5-
Run: `uv run lekiwi_sim`
5+
Run MuJoCo simulation host to be accessed via `lerobot.robot.LekiwiClient`: `uv run lekiwi_sim_host`
6+
7+
Run standalone MuJoCo simulation: `uv run standalone_mujoco_sim`

packages/lekiwi_sim/lekiwi_sim/assets/mjcf_lcmm_robot.xml

Lines changed: 18 additions & 18 deletions
Large diffs are not rendered by default.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import numpy as np
2+
3+
4+
class LeKiwiMobileBase:
5+
"""Class representing the mobile base of the LeKiwi robot.
6+
7+
The mobile base holds the kinematics of a 3-wheeled omnidirectional robot.
8+
Where the wheels are placed at 120 degrees from each other.
9+
10+
The wheel configuration is assumed to be:
11+
- Wheel 1 is at the "left", forming 30 degrees to the forward direction.
12+
- Wheel 2 is at the "right" clockwise 120 degrees from Wheel 1.
13+
- Wheel 3 is at the "back", clockwise 240 degrees from Wheel 1.
14+
"""
15+
16+
def __init__(self, wheel_radius: float, robot_base_radius: float) -> None:
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+
"""
24+
self.wheel_radius = wheel_radius
25+
self.robot_base_radius = robot_base_radius
26+
27+
self.F_matrix = self._compute_forward_kinematics_matrix(self.wheel_radius, self.robot_base_radius)
28+
self.F_matrix_inv = np.linalg.inv(self.F_matrix)
29+
30+
def forward_kinematics(self, wheel_speeds: np.ndarray) -> np.ndarray:
31+
"""Compute the forward kinematics of the mobile base.
32+
33+
Args:
34+
wheel_speeds (np.ndarray): Angular velocities of the wheels [left, right, back] in rad/s.
35+
36+
Returns:
37+
np.ndarray: An array representing the robot's velocity [vx, vy, omega] in m/s and rad/s.
38+
39+
"""
40+
if wheel_speeds.shape != (3,):
41+
raise ValueError("wheel_speeds must be a numpy array of shape (3,)")
42+
43+
# Compute the robot's velocity in the body frame
44+
return self.F_matrix @ wheel_speeds
45+
46+
def inverse_kinematics(self, robot_velocity: np.ndarray) -> np.ndarray:
47+
"""Compute the inverse kinematics of the mobile base.
48+
49+
Args:
50+
robot_velocity (np.ndarray): The desired robot velocity [vx, vy, omega] in m/s and rad/s.
51+
52+
Returns:
53+
np.ndarray: An array representing the angular velocities of the wheels [left, right, back] in rad/s.
54+
55+
"""
56+
if robot_velocity.shape != (3,):
57+
raise ValueError("robot_velocity must be a numpy array of shape (3,)")
58+
59+
# Compute the wheel speeds required to achieve the desired robot velocity
60+
return self.F_matrix_inv @ robot_velocity
61+
62+
@staticmethod
63+
def _compute_forward_kinematics_matrix(r: float, L: float) -> np.ndarray:
64+
"""Calculates the forward kinematics transformation matrix for a 3-wheeled omni-drive robot.
65+
66+
This matrix maps wheel speeds(angular velocities) to robot body speeds(linear and angular velocities)
67+
following the conventions:
68+
- The robot's forward direction is along the positive x-axis.
69+
- The robot's left direction is along the positive y-axis.
70+
- The robot's up direction is along the positive z-axis.
71+
- The robot's angular velocity (omega) is positive for counter-clockwise rotation when viewed from above.
72+
73+
Args:
74+
r (float): The radius of each wheel (in meters).
75+
L (float): The radius of the robot base, from center to wheel (in meters).
76+
77+
Returns:
78+
np.ndarray: A 3x3 transformation matrix that maps wheel speeds to robot body speeds.
79+
80+
"""
81+
return r * np.array(
82+
[[np.sqrt(3) / 2, -np.sqrt(3) / 2, 0], [-1 / 2, -1 / 2, 1], [-1 / (3 * L), -1 / (3 * L), -1 / (3 * L)]]
83+
)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Lekiwi host but for simulation"""
2+
3+
#!/usr/bin/env python
4+
5+
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
6+
#
7+
# Licensed under the Apache License, Version 2.0 (the "License");
8+
# you may not use this file except in compliance with the License.
9+
# You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
19+
import argparse
20+
import json
21+
import logging
22+
import time
23+
24+
# import cv2
25+
import zmq
26+
from lerobot.robots.lekiwi.config_lekiwi import LeKiwiHostConfig
27+
28+
from .robot import LeKiwiMujoco, LeKiwiMujocoConfig
29+
30+
31+
class ZMQHandler:
32+
"""LeKiwi Host agent for simulation."""
33+
34+
def __init__(self, config: LeKiwiHostConfig):
35+
"""Initialize the LeKiwi Host agent for simulation."""
36+
self.zmq_context = zmq.Context()
37+
self.zmq_cmd_socket = self.zmq_context.socket(zmq.PULL)
38+
self.zmq_cmd_socket.setsockopt(zmq.CONFLATE, 1)
39+
self.zmq_cmd_socket.bind(f"tcp://*:{config.port_zmq_cmd}")
40+
41+
self.zmq_observation_socket = self.zmq_context.socket(zmq.PUSH)
42+
self.zmq_observation_socket.setsockopt(zmq.CONFLATE, 1)
43+
self.zmq_observation_socket.bind(f"tcp://*:{config.port_zmq_observations}")
44+
45+
self.connection_time_s = config.connection_time_s
46+
self.watchdog_timeout_ms = config.watchdog_timeout_ms
47+
self.max_loop_freq_hz = config.max_loop_freq_hz
48+
49+
def disconnect(self) -> None:
50+
"""Disconnect the ZMQ sockets and context."""
51+
self.zmq_observation_socket.close()
52+
self.zmq_cmd_socket.close()
53+
self.zmq_context.term()
54+
55+
56+
def main() -> None:
57+
"""Main function to run the LeKiwi simulation host."""
58+
parser = argparse.ArgumentParser(
59+
description="Run the LeKiwi simulation host to be accessed via lerobot.robot.LekiwiClient."
60+
)
61+
62+
parser.add_argument(
63+
"-l",
64+
"--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, format="%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
73+
)
74+
logging.info("Configuring LeKiwi")
75+
robot_config = LeKiwiMujocoConfig()
76+
robot = LeKiwiMujoco(robot_config)
77+
78+
logging.info("Connecting LeKiwi")
79+
robot.connect()
80+
81+
logging.info("Starting HostAgent")
82+
host_config = LeKiwiHostConfig()
83+
host = ZMQHandler(host_config)
84+
85+
last_cmd_time = time.time()
86+
watchdog_active = False
87+
logging.info("Waiting for commands...")
88+
try:
89+
# Business logic
90+
start = time.perf_counter()
91+
while robot.is_connected:
92+
# while duration < host.connection_time_s:
93+
loop_start_time = time.time()
94+
try:
95+
msg = host.zmq_cmd_socket.recv_string(zmq.NOBLOCK)
96+
data = dict(json.loads(msg))
97+
logging.debug("Received command: %s", data)
98+
_action_sent = robot.send_action(data)
99+
last_cmd_time = time.time()
100+
watchdog_active = False
101+
except zmq.Again:
102+
if not watchdog_active:
103+
logging.warning("No command available")
104+
except Exception as e:
105+
logging.error("Message fetching failed: %s", e)
106+
107+
now = time.time()
108+
if (now - last_cmd_time > host.watchdog_timeout_ms / 1000) and not watchdog_active:
109+
logging.warning(
110+
f"Command not received for more than {host.watchdog_timeout_ms} milliseconds. Stopping the base."
111+
)
112+
watchdog_active = True
113+
robot.stop_base()
114+
115+
last_observation = robot.get_observation()
116+
117+
# # Encode ndarrays to base64 strings
118+
# for cam_key, _ in robot.cameras.items():
119+
# ret, buffer = cv2.imencode(
120+
# ".jpg", last_observation[cam_key], [int(cv2.IMWRITE_JPEG_QUALITY), 90]
121+
# )
122+
# if ret:
123+
# last_observation[cam_key] = base64.b64encode(buffer).decode("utf-8")
124+
# else:
125+
# last_observation[cam_key] = ""
126+
127+
# Send the observation to the remote agent
128+
try:
129+
host.zmq_observation_socket.send_string(json.dumps(last_observation), flags=zmq.NOBLOCK)
130+
except zmq.Again:
131+
logging.debug("Dropping observation, no client connected")
132+
133+
# Ensure a short sleep to avoid overloading the CPU.
134+
elapsed = time.time() - loop_start_time
135+
136+
time.sleep(max(1 / host.max_loop_freq_hz - elapsed, 0))
137+
time.perf_counter() - start
138+
print("Cycle time reached.")
139+
140+
except KeyboardInterrupt:
141+
print("Keyboard interrupt received. Exiting...")
142+
finally:
143+
print("Shutting down Lekiwi Host.")
144+
robot.disconnect()
145+
host.disconnect()
146+
147+
logging.info("Finished LeKiwi cleanly")
148+
149+
150+
if __name__ == "__main__":
151+
main()

0 commit comments

Comments
 (0)