Training uses new Robot.py

new Metrics and SimManager for live training viewing
Robot.py usage added to machinelearning
This commit is contained in:
2026-07-31 13:55:24 +02:00
parent 9c31de3c38
commit 61cb0150f0
6 changed files with 510 additions and 261 deletions
+226 -151
View File
@@ -1,130 +1,104 @@
# ml/env.py
"""
ml/env.py - Gymnasium Environment for JackBot Hexapod RL Training
"""
import time
import math
from typing import Optional, Tuple, Dict, Any, List
import gymnasium as gym
from gymnasium import spaces
import numpy as np
import math
import pybullet as p
from config import cfg
from Robot import Robot, PyBulletBackend
from ml.sim_manager import SimManager
from ml.SimManager import SimManager
from ml.MetricsOverlay import MetricsHUD, LeaderCrown
# Color Palette RGBA for Terminated/Failed Robots
COLOR_FAILED = [0.3, 0.3, 0.3, 0.6] # Collapsed / Tilted Robot (Dark Semi-Transparent Gray)
class JackBotEnv(gym.Env):
def __init__(self, use_gui: bool = False, num_robots: int = 1):
self.sim_manager = SimManager(use_gui=use_gui)
"""Gymnasium environment wrapping JackBot hexapods with floating text & crown overlay."""
def __init__(
self,
use_gui: bool = True,
random_command: bool = True,
num_robots: int = 1,
robot_spacing: float = 0.5,
start_pose: str = "init_deg",
max_episode_steps: int = 3000,
urdf_path: str = cfg.urdf_path,
termination_threshold: float = 0.001, # Termination ratio threshold
):
super().__init__()
self.use_gui = use_gui
self.random_command = random_command
self.num_robots = num_robots
self.robot_spacing = robot_spacing
self.start_pose = start_pose
self.max_episode_steps = max_episode_steps
self.urdf_path = urdf_path
self.termination_threshold = termination_threshold
self.episode_count = 0
self.step_count = 0
self.total_steps = 0
self.cumulative_reward = 0.0
self.robot_rewards = [0.0 for _ in range(self.num_robots)]
self.failed_robots_mask = [False for _ in range(self.num_robots)]
self._first_reset = True
# Initialize Simulation Manager
self.sim_manager = SimManager(use_gui=self.use_gui)
self.sim_manager.connect()
# Load simulation bodies
self.plane, self.pb_robots, self.joint_indices = self.sim_manager.load_scene(
cfg.urdf_path, num_robots=num_robots
)
# Instantiate dedicated Robot Python object for EACH spawned robot
self.robots = [
Robot(backend=PyBulletBackend(self.sim_manager, body_id=pb_id))
for pb_id in self.pb_robots
]
# Action & Observation Spaces
action_dim = num_robots * 18
obs_dim = num_robots * (18 + 4)
self.action_space = spaces.Box(-1.0, 1.0, shape=(action_dim,), dtype=np.float32)
self.observation_space = spaces.Box(-np.inf, np.inf, shape=(obs_dim,), dtype=np.float32)
def _robot_base_position(self, robot_id: int, num_robots: int = 1, spacing: float = 0.5) -> list[float]:
"""Calculates grid coordinates for spawning multiple robots in PyBullet."""
cols = int(math.sqrt(num_robots - 1)) + 1
row = robot_id // cols
col = robot_id % cols
x = (col - (cols - 1) / 2.0) * spacing
y = (row - (cols - 1) / 2.0) * spacing
return [x, y, 0.1]
def _connect_sim(self):
self.sim_manager.connect()
self.plane, self.robots, self.robot_joint_indices = self.sim_manager.load_scene(
# Connect physics world
self.plane, self.pb_robots, self.robot_joint_indices = self.sim_manager.load_scene(
self.urdf_path, self.num_robots, self.robot_spacing, self._robot_base_position
)
joint_count = len(self.robot_joint_indices[0])
if any(len(indices) != joint_count for indices in self.robot_joint_indices):
raise ValueError("All robots must have the same number of revolute joints.")
lower_limits, upper_limits = [], []
for joint_index in self.robot_joint_indices[0]:
info = p.getJointInfo(self.robots[0], joint_index)
lower, upper = info[8], info[9]
if lower >= upper:
lower, upper = -math.pi, math.pi
lower_limits.append(lower)
upper_limits.append(upper)
limits = np.array([lower_limits, upper_limits], dtype=np.float32)
self.joint_lower = np.tile(limits[0], (self.num_robots, 1))
self.joint_upper = np.tile(limits[1], (self.num_robots, 1))
pose = ri.init_deg if self.start_pose == "init_deg" else ri.init90_deg
initial_pose = pose.to_rad().data.flatten()
self.initial_angles = np.tile(initial_pose, (self.num_robots, 1)).astype(np.float32)
self.initial_angles = np.clip(self.initial_angles, self.joint_lower, self.joint_upper)
self.last_action = np.zeros(self.num_robots * joint_count, dtype=np.float32)
self.commands = np.zeros((self.num_robots, 4), dtype=np.float32)
def reset(self, command: np.ndarray | None = None, seed: int | None = None, options: dict | None = None):
self.episode_count += 1
self.step_count = 0
self.cumulative_reward = 0.0
if not self._first_reset:
# Reset simulation bodies without reconnecting PyBullet
p.resetSimulation()
p.setGravity(0, 0, -9.81)
self.sim_manager.reset_hud()
self.plane, self.robots, self.robot_joint_indices = self.sim_manager.load_scene(
self.urdf_path, self.num_robots, self.robot_spacing, self._robot_base_position
# Instantiate Robot Python wrappers per PyBullet body ID
self.robots = [
Robot(
backend_type=PyBulletBackend(self.sim_manager, body_id=pb_id),
start_pose=self.start_pose,
urdf_path=self.urdf_path
)
else:
self._first_reset = False
for pb_id in self.pb_robots
]
self.last_action = np.zeros(self.num_robots * len(self.robot_joint_indices[0]), dtype=np.float32)
# Action (18 joint deltas per robot) & Observation (18 angles + 4 command dims per robot)
action_dim = self.num_robots * 18
obs_dim = self.num_robots * (18 + 4)
if seed is not None:
self.seed(seed)
self.action_space = spaces.Box(-1.0, 1.0, shape=(action_dim,), dtype=np.float32)
self.observation_space = spaces.Box(-np.inf, np.inf, shape=(obs_dim,), dtype=np.float32)
if command is not None:
self.commands = np.array(command, dtype=np.float32).reshape(self.num_robots, 4)
elif self.random_command:
self.commands = np.stack([self.sample_command() for _ in range(self.num_robots)])
else:
self.commands = np.zeros((self.num_robots, 4), dtype=np.float32)
self.commands = np.zeros((self.num_robots, 4), dtype=np.float32)
self.last_action = np.zeros(action_dim, dtype=np.float32)
self._reset_pose()
self._update_gui_hud(reward=0.0)
# Floating HUD & Leader Crown Visualizers
self.hud = MetricsHUD(physics_client_id=self.sim_manager.physics_client)
self.leader_crown = LeaderCrown(physics_client_id=self.sim_manager.physics_client)
self.last_time = time.time()
return self._get_obs(), {}
def _set_robot_color(self, pb_id: int, rgba: List[float]):
"""Helper to change the visual color of a robot body and all its links."""
num_joints = p.getNumJoints(pb_id, physicsClientId=self.sim_manager.physics_client)
p.changeVisualShape(pb_id, -1, rgbaColor=rgba, physicsClientId=self.sim_manager.physics_client)
for j in range(num_joints):
p.changeVisualShape(pb_id, j, rgbaColor=rgba, physicsClientId=self.sim_manager.physics_client)
def seed(self, seed: int | None = None):
if seed is None:
seed = int(np.random.randint(0, 2**31 - 1))
random.seed(seed)
np.random.seed(seed)
self._seed = seed
return [seed]
def _reset_pose(self):
for robot, joint_indices, init_angles in zip(self.robots, self.robot_joint_indices, self.initial_angles):
for joint_index, target_angle in zip(joint_indices, init_angles):
p.resetJointState(robot, joint_index, target_angle)
p.setJointMotorControl2(
bodyIndex=robot,
jointIndex=joint_index,
controlMode=p.POSITION_CONTROL,
targetPosition=target_angle,
force=250,
)
for _ in range(400):
p.stepSimulation()
self.joint_angles = self.initial_angles.copy()
def _robot_base_position(self, robot_id: int, num_robots: int = 1, spacing: float = 0.5) -> list[float]:
cols = int(math.sqrt(num_robots - 1)) + 1
row = robot_id // cols
col = robot_id % cols
x = (col - (cols - 1) / 2.0) * spacing
y = (row - (cols - 1) / 2.0) * spacing
return [x, y, 0.2]
def sample_command(self) -> np.ndarray:
vx = np.random.uniform(-1.0, 1.0)
@@ -133,72 +107,173 @@ class JackBotEnv(gym.Env):
omega = np.random.uniform(-1.0, 1.0)
return np.array([vx, vy, vz, omega], dtype=np.float32)
def reset(self, seed: Optional[int] = None, options: Optional[Dict[str, Any]] = None):
super().reset(seed=seed)
self.episode_count += 1
self.step_count = 0
self.cumulative_reward = 0.0
self.robot_rewards = [0.0 for _ in range(self.num_robots)]
self.failed_robots_mask = [False for _ in range(self.num_robots)]
if not self._first_reset:
p.resetSimulation(physicsClientId=self.sim_manager.physics_client)
p.setGravity(0, 0, -9.81, physicsClientId=self.sim_manager.physics_client)
p.configureDebugVisualizer(p.COV_ENABLE_GUI, 0, physicsClientId=self.sim_manager.physics_client)
self.hud.reset()
self.leader_crown.reset()
self.plane, self.pb_robots, self.robot_joint_indices = self.sim_manager.load_scene(
self.urdf_path, self.num_robots, self.robot_spacing, self._robot_base_position
)
for robot_obj, pb_id in zip(self.robots, self.pb_robots):
robot_obj.backend = PyBulletBackend(self.sim_manager, body_id=pb_id)
else:
self._first_reset = False
self.last_action = np.zeros(self.action_space.shape[0], dtype=np.float32)
if self.random_command:
self.commands = np.stack([self.sample_command() for _ in range(self.num_robots)])
else:
self.commands = np.zeros((self.num_robots, 4), dtype=np.float32)
for robot in self.robots:
robot.reset_to_init()
for _ in range(100):
self.sim_manager.step()
self._update_hud()
return self._get_obs(), {}
def _get_obs(self) -> np.ndarray:
return np.concatenate([self.joint_angles.flatten(), self.commands.flatten()]).astype(np.float32)
obs_list = []
for idx, (pb_id, joint_indices) in enumerate(zip(self.pb_robots, self.robot_joint_indices)):
joint_states = p.getJointStates(
pb_id,
joint_indices,
physicsClientId=self.sim_manager.physics_client
)
joint_angles = np.array([state[0] for state in joint_states], dtype=np.float32)
robot_obs = np.concatenate([joint_angles, self.commands[idx]])
obs_list.append(robot_obs)
def step(self, action: np.ndarray):
action_per_robot = action.reshape(len(self.robots), 18)
return np.concatenate(obs_list).astype(np.float32)
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, Dict[str, Any]]:
self.step_count += 1
self.total_steps += 1
self.last_action = action.copy()
action_per_robot = action.reshape(self.num_robots, 18)
# Apply RL actions independently to each Robot object instance
for robot, act in zip(self.robots, action_per_robot):
robot.apply_rl_action(act)
# Step PyBullet physics engine once
self.sim_manager.step()
# Gather observations across all robot objects
obs = np.concatenate([
robot.get_observation(command=np.zeros(4))
for robot in self.robots
])
# Update failure status & gray coloring
self._update_robot_failures()
reward = self._compute_reward()
done = False
return obs, reward, done, False, {}
obs = self._get_obs()
reward, per_robot_step_rewards = self._compute_reward()
self.cumulative_reward += reward
for idx, r_step in enumerate(per_robot_step_rewards):
self.robot_rewards[idx] += r_step
def _update_gui_hud(self, reward: float):
"""Passes current state metrics to the SimManager HUD renderer."""
linear_vel, _ = p.getBaseVelocity(self.robots[0])
cmd = self.commands[0]
terminated = self._is_done()
truncated = self.step_count >= self.max_episode_steps
stats = {
"Episode": self.episode_count,
"Step": f"{self.step_count} / {self.max_episode_steps}",
"Step Reward": reward,
"Total Reward": self.cumulative_reward,
"Target Cmd (vx,vy,w)": [cmd[0], cmd[1], cmd[3]],
"Actual Vel (vx,vy)": [linear_vel[0], linear_vel[1]],
}
self.sim_manager.update_hud(stats)
self._update_hud()
self._update_leader_visuals()
return obs, reward, terminated, truncated, {}
def _compute_reward(self) -> float:
def _compute_reward(self) -> Tuple[float, list[float]]:
rewards = []
for robot_id, robot in enumerate(self.robots):
linear_vel, angular_vel = p.getBaseVelocity(robot)
_, orientation = p.getBasePositionAndOrientation(robot)
for idx, pb_id in enumerate(self.pb_robots):
linear_vel, angular_vel = p.getBaseVelocity(pb_id, physicsClientId=self.sim_manager.physics_client)
_, orientation = p.getBasePositionAndOrientation(pb_id, physicsClientId=self.sim_manager.physics_client)
roll, pitch, _ = p.getEulerFromQuaternion(orientation)
command = self.commands[robot_id]
command = self.commands[idx]
forward_reward = command[0] * linear_vel[0] + command[1] * linear_vel[1]
rotation_reward = command[3] * angular_vel[2]
stability_penalty = abs(roll) + abs(pitch)
action_penalty = float(np.sum(np.square(self.last_action.reshape(self.num_robots, -1)[robot_id]))) * 0.01
action_penalty = float(np.sum(np.square(self.last_action.reshape(self.num_robots, -1)[idx]))) * 0.01
rewards.append(0.1 + forward_reward + rotation_reward - 0.2 * stability_penalty - action_penalty)
r_step = 0.1 + forward_reward + rotation_reward - 0.2 * stability_penalty - action_penalty
rewards.append(r_step)
return float(np.sum(rewards))
return float(np.sum(rewards)), rewards
def _is_done(self) -> bool:
for robot in self.robots:
_, orientation = p.getBasePositionAndOrientation(robot)
roll, pitch, _ = p.getEulerFromQuaternion(orientation)
if abs(roll) > 0.7 or abs(pitch) > 0.7:
return True
position, _ = p.getBasePositionAndOrientation(robot)
if position[2] < 0.02:
return True
"""Returns True only when the percentage of failed robots exceeds the threshold."""
failed_count = sum(self.failed_robots_mask)
failure_ratio = failed_count / self.num_robots
return failure_ratio >= self.termination_threshold
return self.step_count >= self.max_episode_steps
def _update_hud(self):
if not self.use_gui or not self.pb_robots:
return
now = time.time()
fps = 1.0 / max(now - self.last_time, 1e-5)
self.last_time = now
heights = []
rolls = []
pitches = []
for pb_id in self.pb_robots:
pos, orient = p.getBasePositionAndOrientation(pb_id, physicsClientId=self.sim_manager.physics_client)
roll, pitch, _ = p.getEulerFromQuaternion(orient)
heights.append(pos[2])
rolls.append(math.degrees(roll))
pitches.append(math.degrees(pitch))
avg_height = float(np.mean(heights))
avg_roll_pitch = (float(np.mean(rolls)), float(np.mean(pitches)))
self.hud.update(
episode=self.episode_count,
step=self.total_steps,
robot_rewards=self.robot_rewards,
cmd_vel=self.commands[0],
fps=fps,
avg_height=avg_height,
roll_pitch=avg_roll_pitch
)
def _update_leader_visuals(self):
"""Positions floating crown above top robot without altering active materials."""
if not self.use_gui or self.num_robots <= 1:
return
best_idx = int(np.argmax(self.robot_rewards))
leader_pb_id = self.pb_robots[best_idx]
leader_pos, _ = p.getBasePositionAndOrientation(
leader_pb_id, physicsClientId=self.sim_manager.physics_client
)
self.leader_crown.update(leader_pos)
def _update_robot_failures(self):
"""Checks failure condition for each robot and turns failed ones gray."""
for idx, pb_id in enumerate(self.pb_robots):
if self.failed_robots_mask[idx]:
continue # Already marked failed
position, orientation = p.getBasePositionAndOrientation(
pb_id, physicsClientId=self.sim_manager.physics_client
)
roll, pitch, _ = p.getEulerFromQuaternion(orientation)
is_tilted = abs(roll) > 0.7 or abs(pitch) > 0.7
is_collapsed = position[2] < 0.05
if is_tilted or is_collapsed:
self.failed_robots_mask[idx] = True
if self.use_gui:
# Turn failed robot semi-transparent dark gray
self._set_robot_color(pb_id, COLOR_FAILED)
def close(self):
self.sim_manager.disconnect()