# mypy: disable-error-code="attr-defined, name-defined"
"""This module adds the notion of hardware and option TOML configuration files
for robots. It provides utilities for generating them automatically and a basic
wrapper on top of the lower-level Jiminy Robot for loading them.
"""
import os
import re
import logging
import pathlib
import tempfile
import xml.etree.ElementTree as ET
from collections import OrderedDict, defaultdict
from types import ModuleType
from typing import Optional, Dict, Any, Sequence, Literal, Set, List, get_args
import toml
import numpy as np
import trimesh
import trimesh.parent
import hppfcl
import pinocchio as pin
from pinocchio.rpy import rpyToMatrix # pylint: disable=import-error
from . import core as jiminy
from .core import ( # pylint: disable=no-name-in-module
SimpleMotor, EncoderSensor, EffortSensor, ForceSensor, ImuSensor)
DEFAULT_UPDATE_RATE = 1000.0 # [Hz]
DEFAULT_FRICTION_DRY_SLOPE = 20.0
EXTENSION_MODULES: Sequence[ModuleType] = ()
GeometryModelType = Literal['collision', 'visual']
GeometryObjectType = Literal['primitive', 'mesh']
class _DuplicateFilter(logging.Filter):
"""Basic logging filter for printing out identical logging messages only
once regardless its origin by keeping track of the whole history.
"""
def __init__(self) -> None:
super().__init__()
self.msgs: Set[str] = set()
def filter(self, record: logging.LogRecord) -> bool:
"""Filter all messages that have already been logged once.
All new messages are stored in a singleton buffer.
"""
if record.msg not in self.msgs:
self.msgs.add(record.msg)
return False
return True
LOGGER = logging.getLogger(__name__)
LOGGER.addFilter(_DuplicateFilter())
def _gcd(a: float,
b: float,
rtol: float = 1.0e-05,
atol: float = 1.0e-08) -> float:
"""Compute the greatest common divisor of two float numbers.
"""
t = min(abs(a), abs(b))
while abs(b) > rtol * t + atol:
a, b = b, a % b
return a
def _fix_urdf_mesh_path(urdf_path: str,
mesh_path_dir: str,
output_root_path: Optional[str] = None) -> str:
"""Generate an URDF with updated mesh paths.
:param urdf_path: Full path of the URDF file.
:param mesh_path_dir: Root path of the meshes.
:param output_root_path: Root directory of the fixed URDF file.
Optional: temporary directory by default.
:returns: Full path of the fixed URDF file.
"""
# Extract all the mesh path that are not package path, continue if any
with open(urdf_path, 'r') as urdf_file:
urdf_contents = urdf_file.read()
mesh_tag = "<mesh filename="
pathlists = {
filename
for filename in re.findall(mesh_tag + '"(.*)"', urdf_contents)
if not filename.startswith('package://')}
if not pathlists:
return urdf_path
# If mesh root path already matching, then nothing to do
if len(pathlists) > 1:
if all(path.startswith('.') for path in pathlists):
mesh_path_dir_orig = '.'
else:
mesh_path_dir_orig = os.path.commonpath(list(pathlists))
else:
mesh_path_dir_orig = os.path.dirname(next(iter(pathlists)))
if mesh_path_dir == mesh_path_dir_orig:
return urdf_path
# Create the output directory
if output_root_path is None:
output_root_path = tempfile.mkdtemp()
fixed_urdf_dir = os.path.join(
output_root_path, "fixed_urdf" + mesh_path_dir.translate(
str.maketrans({k: '_' for k in '/:'}))) # type: ignore[arg-type]
os.makedirs(fixed_urdf_dir, exist_ok=True)
fixed_urdf_path = os.path.join(
fixed_urdf_dir, os.path.basename(urdf_path))
# Override the root mesh path with the desired one
urdf_contents = urdf_contents.replace(
'"'.join((mesh_tag, mesh_path_dir_orig)),
'"'.join((mesh_tag, mesh_path_dir)))
with open(fixed_urdf_path, 'w') as f:
f.write(urdf_contents)
return fixed_urdf_path
[docs]
def generate_default_hardware_description_file(
urdf_path: str,
hardware_path: Optional[str] = None,
default_update_rate: float = DEFAULT_UPDATE_RATE,
verbose: bool = True) -> None:
r"""Generate a default hardware description file, based on the information
grabbed from the URDF when available, using educated guess otherwise.
If no IMU sensor is found, a single one is added on the root body of the
kinematic tree. If no Gazebo plugin is available, collision bodies and
force sensors are added on every leaf body of the robot. Otherwise, the
definition of the plugins in use to infer them.
'joint' fields are parsed to extract every joint, actuated or not. 'fixed'
joints are not considered as actual joints. Transmission fields are parsed
to determine which one of those joints are actuated. If no transmission is
found, it is assumed that every joint is actuated, with a transmission
ratio of 1:1.
It is assumed that:
- every joint has an encoder attached,
- every actuated joint has an effort sensor attached,
- every collision body has a force sensor attached
- for every Gazebo contact sensor, the associated body is added
to the set of the collision bodies, but it is not the case
for Gazebo force plugin.
When the default update rate is unspecified, then the default
sensor update rate is 1KHz if no Gazebo plugin has been found,
otherwise the highest one among found plugins will be used.
.. note::
It has been primarily designed for robots with freeflyer. The default
configuration should work out-of-the-box for walking robot, but
substantial modification may be required for different types of robots
such as wheeled robots or robotics manipulator arms.
.. note::
TOML format as be chosen to make reading and manually editing of the
file as human-friendly as possible.
:param urdf_path: Fullpath of the URDF file.
:param hardware_path: Fullpath of the hardware description file.
Optional: By default, it is the same location than
the URDF file, using '\*_hardware.toml' extension.
:param default_update_rate: Default update rate of the sensors and the
controller in Hz. It will be used for sensors
whose the update rate is unspecified. 0.0 for
continuous update.
Optional: DEFAULT_UPDATE_RATE if no Gazebo
plugin has been found, the lowest among the
Gazebo plugins otherwise.
:param verbose: Whether to print warnings.
"""
# Handle verbosity level
if verbose:
LOGGER.setLevel(logging.DEBUG)
else:
LOGGER.setLevel(logging.ERROR)
# Read the XML
tree = ET.parse(urdf_path)
root = tree.getroot()
# Initialize the hardware information
hardware_info: Dict[str, Dict[str, Any]] = OrderedDict(
Global=OrderedDict(
sensorsUpdatePeriod=1.0/default_update_rate,
controllerUpdatePeriod=1.0/default_update_rate,
collisionBodyNames=[],
contactFrameNames=[]
),
Motor=defaultdict(OrderedDict),
Sensor=defaultdict(OrderedDict)
)
motors_info = hardware_info['Motor']
sensors_info = hardware_info['Sensor']
# Extract the root link. It is the one having no parent at all.
links = set()
for link_descr in root.findall('./link'):
links.add(link_descr.attrib["name"])
for joint_descr in root.findall('./joint'):
child_obj = joint_descr.find('./child')
assert child_obj is not None
links.remove(child_obj.get('link'))
link_root = next(iter(links))
# Extract the list of parent and child links, excluding the one related
# to fixed link not having collision geometry, because they are likely not
# "real" joint.
links_parent: Set[str] = set()
links_child: Set[str] = set()
for joint_descr in root.findall('./joint'):
parent_link_obj = joint_descr.find('./parent')
child_link_obj = joint_descr.find('./child')
assert parent_link_obj is not None
assert child_link_obj is not None
parent_link = parent_link_obj.attrib['link']
child_link = child_link_obj.attrib['link']
if joint_descr.attrib['type'].casefold() != 'fixed' or root.find(
f"./link[@name='{child_link}']/collision") is not None:
links_parent.add(parent_link)
links_child.add(child_link)
# Determine leaf links. If there is no parent, then use root link instead.
if links_parent:
links_leaf = sorted(list(links_child.difference(links_parent)))
else:
links_leaf = [link_root]
# Parse the gazebo plugins, if any.
# Note that it is only useful to extract "advanced" hardware, not basic
# motors, encoders and effort sensors.
gazebo_ground_stiffness: Optional[float] = None
gazebo_ground_damping: Optional[float] = None
gazebo_update_rate: Optional[float] = None
collision_body_names: Set[str] = set()
gazebo_plugins_found = root.find('gazebo') is not None
for gazebo_plugin_descr in root.iterfind('gazebo'):
body_name = gazebo_plugin_descr.attrib['reference']
# Extract sensors
for gazebo_sensor_descr in gazebo_plugin_descr.iterfind('sensor'):
sensor_info = OrderedDict(body_name=body_name)
# Extract the sensor name
sensor_name = gazebo_sensor_descr.attrib['name']
# Extract the sensor type
sensor_type = gazebo_sensor_descr.attrib['type'].casefold()
if 'imu' in sensor_type:
sensor_type = ImuSensor.type
elif 'contact' in sensor_type:
collision_body_names.add(body_name)
sensor_type = ForceSensor.type
else:
LOGGER.warning(
"Unsupported Gazebo sensor plugin of type '%s'.",
sensor_type)
continue
# Extract the sensor update period
update_rate_obj = gazebo_sensor_descr.find('./update_rate')
assert update_rate_obj is not None
assert update_rate_obj.text is not None
update_rate = float(update_rate_obj.text)
if gazebo_update_rate is None:
gazebo_update_rate = update_rate
elif gazebo_update_rate != update_rate:
LOGGER.warning(
"Jiminy does not support sensors with different "
"update rate. Using greatest common divisor instead.")
gazebo_update_rate = _gcd(gazebo_update_rate, update_rate)
# Extract the pose of the frame associate with the sensor.
# Note that it is optional but usually defined since sensors
# can only be attached to link in Gazebo, not to frame.
frame_pose_obj = gazebo_sensor_descr.find('./pose')
if frame_pose_obj is None:
sensor_info['frame_name'] = sensor_info.pop('body_name')
else:
assert frame_pose_obj.text is not None
sensor_info['frame_pose'] = list(
map(float, frame_pose_obj.text.split()))
# Add the sensor to the robot's hardware
sensors_info[sensor_type][sensor_name] = sensor_info
# Extract the collision bodies and ground model, then add force
# sensors. At this point, every force sensor is associated with a
# collision body.
if gazebo_plugin_descr.find('kp') is not None:
# Add a force sensor, if not already in the collision set
if body_name not in collision_body_names:
force_sensor_info = sensors_info[ForceSensor.type]
force_sensor_info[f"{body_name}Contact"] = OrderedDict(
frame_name=body_name)
# Add the related body to the collision set
collision_body_names.add(body_name)
# Update the ground model
kp_obj = gazebo_plugin_descr.find('kp')
kd_obj = gazebo_plugin_descr.find('kd')
assert kp_obj is not None and kp_obj.text is not None
assert kd_obj is not None and kd_obj.text is not None
ground_stiffness = float(kp_obj.text)
ground_damping = float(kd_obj.text)
if gazebo_ground_stiffness is None:
gazebo_ground_stiffness = ground_stiffness
if gazebo_ground_damping is None:
gazebo_ground_damping = ground_damping
if (gazebo_ground_stiffness != ground_stiffness or
gazebo_ground_damping != ground_damping):
raise RuntimeError(
"Jiminy does not support contacts with different ground "
"models.")
# Extract plugins not wrapped into a sensor
for gazebo_plugin_descr in gazebo_plugin_descr.iterfind('plugin'):
plugin = gazebo_plugin_descr.attrib['filename']
if plugin == "libgazebo_ros_force.so":
body_name_obj = gazebo_plugin_descr.find('bodyName')
assert body_name_obj is not None
body_name = body_name_obj.text
force_sensor_info = sensors_info[ForceSensor.type]
force_sensor_info[f"{body_name}Wrench"] = OrderedDict(
frame_name=body_name)
else:
LOGGER.warning("Unsupported Gazebo plugin '%s'", plugin)
# Add IMU sensor to the root link if no Gazebo IMU sensor has been found
if link_root and ImuSensor.type not in sensors_info.keys():
sensors_info[ImuSensor.type][link_root] = OrderedDict(
frame_name=link_root)
# Add force sensors and collision bodies if no Gazebo plugin is available
if not gazebo_plugins_found:
for link_leaf in links_leaf:
# Add a force sensor
sensors_info[ForceSensor.type][link_leaf] = OrderedDict(
frame_name=link_leaf)
# Add the related body to the collision set if possible
if root.find(f"./link[@name='{link_leaf}']/collision") is not None:
collision_body_names.add(link_leaf)
# Specify collision bodies and ground model in global config options
hardware_info['Global']['collisionBodyNames'] = sorted(
list(collision_body_names))
if gazebo_ground_stiffness is not None:
hardware_info['Global']['groundStiffness'] = gazebo_ground_stiffness
if gazebo_ground_damping is not None:
hardware_info['Global']['groundDamping'] = gazebo_ground_damping
# Extract joint dynamics properties, namely 'friction' and 'damping'
joints_options: Dict[str, Dict[str, Any]] = {}
for joint_descr in root.findall("./joint"):
if joint_descr.attrib['type'].casefold() == 'fixed':
continue
joint_name = joint_descr.attrib['name']
dyn_descr = joint_descr.find('./dynamics')
if dyn_descr is not None:
damping = float(dyn_descr.get('damping', 0.0))
friction = float(dyn_descr.get('friction', 0.0))
else:
damping, friction = 0.0, 0.0
joints_options[joint_name] = OrderedDict()
if damping > 0.0:
joints_options[joint_name].update(
frictionViscousPositive=-damping,
frictionViscousNegative=-damping
)
if friction > 0.0:
joints_options[joint_name].update(
frictionDryPositive=-friction,
frictionDryNegative=-friction,
frictionDrySlope=DEFAULT_FRICTION_DRY_SLOPE
)
# Extract the motors and effort sensors.
# It is done by reading 'transmission' field, that is part of
# URDF standard, so it should be available on any URDF file.
joint_transmissions = set()
for transmission_descr in root.iterfind('transmission'):
# Assert(s) for type checker
assert isinstance(transmission_descr, ET.Element)
# Initialize motor and sensor info
motor_info: Dict[str, Any] = OrderedDict()
sensor_info = OrderedDict()
# Check that the transmission type is supported
transmission_name = transmission_descr.attrib['name']
transmission_type_obj = transmission_descr.find('./type')
if transmission_type_obj is not None:
transmission_type = transmission_type_obj.text
else:
transmission_type = transmission_descr.attrib['type']
assert transmission_type is not None
transmission_type = os.path.basename(transmission_type).casefold()
if transmission_type != 'simpletransmission':
LOGGER.warning(
"Jiminy only support SimpleTransmission for now. Skipping"
"transmission '%s' of type '%s'.", transmission_name,
transmission_type)
continue
# Extract the motor name
motor_descr = transmission_descr.find('./actuator')
assert isinstance(motor_descr, ET.Element)
motor_name = motor_descr.attrib['name']
sensor_info['motor_name'] = motor_name
# Extract the associated joint name
joint_descr = transmission_descr.find('./joint')
assert isinstance(joint_descr, ET.Element)
joint_name = joint_descr.attrib['name']
motor_info['joint_name'] = joint_name
joint_transmissions.add(joint_name)
# Make sure that the joint is revolute
joint = root.find(f"./joint[@name='{joint_name}']")
assert joint is not None
joint_type = joint.attrib['type'].casefold()
if joint_type not in ("revolute", "continuous", "prismatic"):
LOGGER.warning(
"Jiminy only support 1-dof joint actuators and effort "
"sensors. Attached joint cannot of type '%s'.", joint_type)
continue
# Extract the transmission ratio (motor / joint)
ratio_obj = transmission_descr.find('./mechanicalReduction')
if ratio_obj is None:
motor_info['mechanicalReduction'] = 1.0
else:
assert ratio_obj.text is not None
ratio_txt = ratio_obj.text
motor_info['mechanicalReduction'] = float(ratio_txt)
# Extract the armature (rotor) inertia
armature = transmission_descr.find('./motorInertia')
if armature is None:
motor_info['armature'] = 0.0
else:
armature_txt = armature.text
assert armature_txt is not None
motor_info['armature'] = float(armature_txt)
# Add dynamics property to motor info, if any
motor_info.update(joints_options.pop(joint_name))
# Add the motor and sensors to the robot's hardware
motors_info[SimpleMotor.__name__][motor_name] = motor_info
sensors_info[EncoderSensor.type][motor_name] = sensor_info
sensors_info[EffortSensor.type][motor_name] = sensor_info
# Define default encoder sensors, and default effort sensors if no
# transmission available.
for joint_descr in root.iterfind('joint'):
encoder_info = OrderedDict()
# Skip fixed joints
joint_type = joint_descr.attrib['type'].casefold()
if joint_type == 'fixed':
continue
# Extract the joint name
joint_name = joint_descr.attrib['name']
encoder_info['joint_name'] = joint_name
# Add the sensor to the robot's hardware
if joint_name not in joint_transmissions:
sensors_info[EncoderSensor.type][joint_name] = encoder_info
# Add motors to robot hardware by default if no transmission found
if not joint_transmissions:
joint_limit_descr = joint_descr.find('./limit')
assert joint_limit_descr is not None
if float(joint_limit_descr.attrib['effort']) == 0.0:
continue
motors_info[SimpleMotor.type][joint_name] = OrderedDict(
joint_name=joint_name,
armature=0.0,
**joints_options.pop(joint_name)
)
sensors_info[EffortSensor.type][joint_name] = OrderedDict(
motor_name=joint_name)
# Warn if friction model has been defined for non-actuated joints
if joints_options:
LOGGER.warning(
"Jiminy only support friction model for actuated joint.")
# Specify custom update rate for the controller and the sensors, if any
if gazebo_update_rate is not None:
hardware_info['Global']['sensorsUpdatePeriod'] = \
1.0 / gazebo_update_rate
hardware_info['Global']['controllerUpdatePeriod'] = \
1.0 / gazebo_update_rate
# Write the sensor description file
if hardware_path is None:
hardware_path = str(pathlib.Path(
urdf_path).with_suffix('')) + '_hardware.toml'
with open(hardware_path, 'w') as f:
toml.dump(hardware_info, f)
[docs]
def load_hardware_description_file(
robot: jiminy.Robot,
hardware_path: str,
avoid_instable_collisions: bool = True,
verbose: bool = True) -> Dict[str, Any]:
"""Load hardware configuration file.
If no collision geometry is associated with the body requiring collision
handling, then the visual geometry is used instead, if any. If none is
available despite at, then a single contact point is added at body frame.
For now, every mesh used for collision are replaced by the vertices of the
associated minimum volume bounding box, to avoid numerical instabilities.
:param robot: Jiminy robot.
:param hardware_path: Path of Jiminy hardware description toml file.
:param avoid_instable_collisions: Prevent numerical instabilities by
replacing collision mesh by vertices of
associated minimal volume bounding box,
and primitive box by its vertices.
:param verbose: Whether to print warnings.
:returns: Unused information available in hardware configuration file.
"""
# Handle verbosity level
if verbose:
LOGGER.setLevel(logging.DEBUG)
else:
LOGGER.setLevel(logging.ERROR)
hardware_info = toml.load(hardware_path)
extra_info = hardware_info.pop('Global', {})
motors_info = hardware_info.pop('Motor', {})
sensors_info = hardware_info.pop('Sensor', {})
# Extract the list of bodies having visual and collision meshes or
# primitives.
geometry_types: Dict[
GeometryModelType, Dict[GeometryObjectType, Set[str]]] = {
geom_type: {'primitive': set(), 'mesh': set()}
for geom_type in get_args(GeometryModelType)}
geometry_specs: Dict[GeometryModelType, Dict[GeometryObjectType, Dict[
GeometryObjectType, List[pin.GeometryObject]]]] = {
geom_type: {
'primitive': defaultdict(lambda: []),
'mesh': defaultdict(lambda: [])}
for geom_type in get_args(GeometryModelType)}
for geom_model, geometry_types_i, geometry_specs_i in zip(
(robot.collision_model, robot.visual_model),
geometry_types.values(),
geometry_specs.values()):
for geometry_object in geom_model.geometryObjects:
frame_index = geometry_object.parentFrame
frame_name = robot.pinocchio_model.frames[frame_index].name
mesh_path = geometry_object.meshPath
is_mesh = any(char in mesh_path for char in ('\\', '/', '.'))
geom_type: GeometryObjectType = 'mesh' if is_mesh else 'primitive'
geometry_types_i[geom_type].add(frame_name)
geometry_specs_i[geom_type][frame_name].append(geometry_object)
# Checking the collision bodies, to make sure they are associated with
# supported collision geometries. If not, fixing the issue after
# throwing a warning.
collision_body_names = extra_info.pop('collisionBodyNames', [])
contact_frame_names = extra_info.pop('contactFrameNames', [])
for body_name in collision_body_names.copy():
# Filter out the different cases.
# After this filter, we know that their is no collision geometry
# associated with the body but their is a visual mesh, or there is
# only collision meshes.
if body_name in geometry_types['collision']['mesh'] and \
body_name in geometry_types['collision']['primitive']:
if not avoid_instable_collisions:
continue
LOGGER.warning(
"Collision body having both primitive and mesh geometries "
"is not supported. Enabling only primitive collision for "
"this body.")
continue
if body_name in geometry_types['collision']['primitive']:
pass
elif body_name in geometry_types['collision']['mesh']:
if not avoid_instable_collisions:
continue
LOGGER.warning(
"Collision body associated with mesh geometry is not "
"supported for now. Replacing it by contact points at the "
"vertices of the minimal volume bounding box.")
elif body_name not in geometry_types['visual']['mesh']:
LOGGER.warning(
"No visual mesh nor collision geometry associated with "
"collision body '%s'. Fallback to adding a single contact "
"point at body frame.", body_name)
contact_frame_names.append(body_name)
continue
else:
LOGGER.warning(
"No collision geometry associated with the collision body "
"'%s'. Fallback to replacing it by contact points at the "
"vertices of the minimal volume bounding box of the available "
"visual meshes.", body_name)
# Check if collision primitive box are available
collision_boxes_size, collision_boxes_origin = [], []
for geometry_object in \
geometry_specs['collision']['primitive'][body_name]:
geom = geometry_object.geometry
if isinstance(geom, hppfcl.Box):
collision_boxes_size.append(2.0 * geom.halfSide)
collision_boxes_origin.append(geometry_object.placement)
# Replace the collision boxes by contact points, if any
if collision_boxes_size:
if not avoid_instable_collisions:
continue
LOGGER.warning(
"Collision body associated with box geometry is not "
"numerically stable for now. Replacing it by contact points "
"at the vertices.")
for i, (box_size, box_origin) in enumerate(zip(
collision_boxes_size, collision_boxes_origin)):
vertices = [e.flatten() for e in np.meshgrid(*[
0.5 * v * np.array([-1.0, 1.0]) for v in box_size])]
for j, (x, y, z) in enumerate(zip(*vertices)):
frame_name = "_".join((
body_name, "CollisionBox", str(i), str(j)))
vertex_pos_rel = pin.SE3(
np.eye(3), np.array([x, y, z]))
frame_transform = box_origin.act(vertex_pos_rel)
robot.add_frame(frame_name, body_name, frame_transform)
contact_frame_names.append(frame_name)
elif body_name in geometry_types['collision']['primitive']:
# Do nothing if the primitive is not a box. It should be fine.
continue
# Remove the body from the collision detection set
collision_body_names.remove(body_name)
# Early return if collision box primitives have been replaced
if collision_boxes_size:
continue
# Replace the collision bodies by contact points, falling back to
# visual bodies if none.
for geometry_object in (
geometry_specs['collision']['mesh'][body_name] or
geometry_specs['visual']['mesh'][body_name]):
# Extract info from geometry object
mesh_name = geometry_object.name
mesh_path = geometry_object.meshPath
mesh_scale = geometry_object.meshScale
mesh_origin = geometry_object.placement
# Replace relative mesh path by absolute one
if mesh_path.startswith("package://"):
mesh_path_orig = mesh_path
for root_dir in robot.mesh_package_dirs:
mesh_path = mesh_path_orig.replace(
"package:/", root_dir)
if os.path.exists(mesh_path):
break
# Compute the minimal volume bounding box, then
try:
mesh = trimesh.load(mesh_path)
except ValueError: # Mesh file is not available
continue
# Assert(s) for type checker
assert isinstance(mesh, trimesh.parent.Geometry)
# Extract oriented bounding box
box = mesh.bounding_box_oriented
# Add new frames to the robot model at its vertices and register
# contact points at their respective location.
for i in range(8):
frame_name = "_".join((mesh_name, "BoundingBox", str(i)))
frame_transform_rel = pin.SE3(
np.eye(3), mesh_scale * np.asarray(box.vertices[i]))
frame_transform = mesh_origin.act(frame_transform_rel)
robot.add_frame(frame_name, body_name, frame_transform)
contact_frame_names.append(frame_name)
# Add the collision bodies and contact points.
# * It must be done before adding the sensors because Contact sensors
# requires contact points to be defined.
# * Mesh collisions is not numerically stable for now, so disabling it.
# * Be careful, the order of the contact points is important, it changes
# the computation of the external forces, which is an iterative algorithm
# for impulse model, resulting in different simulation results. The order
# of the element of the set depends of the `hash` method of python, whose
# seed is randomly generated when starting the interpreter for security
# reason. As a result, the set must be sorted manually to ensure consistent
# results.
robot.add_collision_bodies(
collision_body_names, ignore_meshes=avoid_instable_collisions)
robot.add_contact_points(sorted(list(set(contact_frame_names))))
# Add the motors to the robot
for motor_type, motors_descr in motors_info.items():
for motor_name, motor_descr in motors_descr.items():
# Make sure the motor can be instantiated
joint_name = motor_descr.pop('joint_name')
if not robot.pinocchio_model.existJointName(joint_name):
LOGGER.warning("'%s' is not a valid joint name.", joint_name)
continue
# Create the motor and attach it
motor = None
for module in (jiminy, *EXTENSION_MODULES):
try:
motor = getattr(module, motor_type)(motor_name)
break
except AttributeError:
pass
if motor is None:
raise RuntimeError(
f"Cannot instantiate motor of type '{motor_type}'.")
robot.attach_motor(motor)
# Initialize the motor
motor.initialize(joint_name)
# Set the motor options
options = motor.get_options()
option_fields = options.keys()
for name, value in motor_descr.items():
if name not in option_fields:
LOGGER.warning(
"'%s' is not a valid option for the motor '%s' of "
"type '%s'.", name, motor_name, motor_type)
options[name] = value
options['enableArmature'] = True
motor.set_options(options)
# Add the sensors to the robot
motor_names = [motor.name for motor in robot.motors]
for sensor_type, sensors_descr in sensors_info.items():
for sensor_name, sensor_descr in sensors_descr.items():
# Extract initialization arguments from options
init_kwargs = {
field: sensor_descr.pop(field)
for field in (
'joint_name',
'motor_name',
'frame_name',
'body_name',
'frame_pose')
if field in sensor_descr}
# Make sure the sensor can be instantiated
joint_name = init_kwargs.get('joint_name', None)
if joint_name is not None:
init_kwargs['joint_name'] = joint_name
if not robot.pinocchio_model.existJointName(joint_name):
raise ValueError(
f"'{joint_name}' is not a valid joint name.")
motor_name = init_kwargs.get('motor_name', None)
if motor_name is not None:
init_kwargs['motor_name'] = motor_name
if motor_name not in motor_names:
raise ValueError(
f"'{motor_name}' is not a valid motor name.")
frame_name = init_kwargs.get('frame_name', None)
if frame_name is not None:
# Create a frame if a frame name has been specified.
# In such a case, the body name must be specified.
if not robot.pinocchio_model.existFrame(frame_name):
# Get the body name
body_name = init_kwargs.pop('body_name')
# Generate a frame name both intelligible and available
if frame_name is None:
i = 0
frame_name = "_".join((
sensor_name, sensor_type, "Frame"))
while robot.pinocchio_model.existFrame(frame_name):
frame_name = "_".join((
sensor_name, sensor_type, "Frame", str(i)))
i += 1
# Compute SE3 object representing the frame placement
frame_pose_xyzrpy = np.array(init_kwargs.pop('frame_pose'))
frame_trans = frame_pose_xyzrpy[:3]
frame_rot = rpyToMatrix(frame_pose_xyzrpy[3:])
frame_placement = pin.SE3(frame_rot, frame_trans)
# Add the frame to the robot model
robot.add_frame(frame_name, body_name, frame_placement)
# Add newly created frame to initialization options
init_kwargs['frame_name'] = frame_name
elif 'frame_pose' in init_kwargs.keys():
raise ValueError(
f"The sensor '{sensor_name}' is attached to the frame "
f"'{frame_name}' that already exists whereas a "
"specific pose is also requested.")
# Create the sensor and attach it
for module in (jiminy, *EXTENSION_MODULES):
try:
sensor = getattr(module, sensor_type)(sensor_name)
break
except AttributeError:
pass
else:
raise RuntimeError(
f"Cannot instantiate sensor of type '{sensor_type}'.")
robot.attach_sensor(sensor)
# Initialize the sensor
sensor.initialize(**init_kwargs)
# Set the sensor options
options = sensor.get_options()
option_fields = options.keys()
for name, value in sensor_descr.items():
if name not in option_fields:
LOGGER.warning(
"'%s' is not a valid option for the sensor '%s' of "
"type '%s'.", name, sensor_name, sensor_type)
options[name] = value
sensor.set_options(options)
return extra_info
[docs]
class BaseJiminyRobot(jiminy.Robot):
"""Base class to instantiate a Jiminy robot based on a standard URDF file
and Jiminy-specific hardware description file.
The utility 'generate_default_hardware_description_file' is provided to
automatically generate a default hardware description file for any given
URDF file. URDF file containing Gazebo plugins description should not
require any further modification as it usually includes the information
required to fully characterize the motors, sensors, contact points and
collision bodies, along with some of there properties.
.. note::
Overload this class if you need finer-grained capability.
.. warning::
Hardware description files within the same directory and having the
name than the URDF file will be detected automatically without
requiring to manually specify its path.
:param name: Name of the robot to be created.
Optional: Empty string by default.
"""
def __init__(self, name: str = "") -> None:
self.extra_info: Dict[str, Any] = {}
self.hardware_path: Optional[str] = None
self._urdf_path_orig: Optional[str] = None
super().__init__(name)
[docs]
def initialize(self, # type: ignore[override]
urdf_path: str,
hardware_path: Optional[str] = None,
mesh_path_dir: Optional[str] = None,
mesh_package_dirs: Sequence[str] = (),
has_freeflyer: bool = True,
avoid_instable_collisions: bool = True,
load_visual_meshes: bool = False,
verbose: bool = True) -> None:
r"""Initialize the robot.
:param urdf_path: Path of the URDF file of the robot.
:param hardware_path: Path of Jiminy hardware description toml file.
Optional: Looking for '\*_hardware.toml' file in
the same folder and with the same name. If not
found, then no hardware is added to the robot,
which is valid and can be used for display.
:param mesh_path_dir: Path to the folder containing the URDF meshes. It
will overwrite the common root of all absolute
mesh paths.
Optional: Env variable 'JIMINY_DATA_PATH' will be
used if available.
:param mesh_package_dirs: Additional search paths for all relative mesh
paths beginning with 'packages://' directive.
'mesh_path_dir' is systematically appended.
:param has_freeflyer: Whether the robot is fixed-based wrt its root
link, or can move freely in the world.
:param avoid_instable_collisions: Prevent numerical instabilities by
replacing collision mesh by vertices
of associated minimal volume bounding
box, primitive box by its vertices,
and primitive sphere by its center.
:param load_visual_meshes: Load visual and collision geometries when
creating the robot. It will allow for
dumping standalone log files that are
safe to carry around but larger.
:param verbose: Whether to print warnings.
"""
# Backup the original URDF path
self._urdf_path_orig = urdf_path
# Fix the URDF mesh paths
if mesh_path_dir is not None:
urdf_path = _fix_urdf_mesh_path(urdf_path, mesh_path_dir)
# Initialize the robot without motors nor sensors
mesh_package_dirs = list(mesh_package_dirs)
if mesh_path_dir is not None:
mesh_package_dirs.append(mesh_path_dir)
else:
mesh_package_dirs.append(os.path.dirname(urdf_path))
mesh_env_path = os.environ.get('JIMINY_DATA_PATH', None)
if mesh_env_path is not None:
mesh_package_dirs.append(mesh_env_path)
super().initialize(
urdf_path, has_freeflyer, mesh_package_dirs, load_visual_meshes)
# Load the hardware description file if available
if hardware_path is None:
hardware_path = str(pathlib.Path(
self._urdf_path_orig).with_suffix('')) + '_hardware.toml'
self.hardware_path = hardware_path
if not os.path.exists(hardware_path):
if hardware_path:
LOGGER.warning(
"Hardware configuration file not found. Not adding any "
"hardware to the robot.\n Default file can be generated "
"using 'generate_default_hardware_description_file' "
"method.")
return
self.extra_info = load_hardware_description_file(
self, hardware_path, avoid_instable_collisions, verbose)
def __del__(self) -> None:
if self.urdf_path != self._urdf_path_orig:
try:
os.remove(self.urdf_path)
except (PermissionError, FileNotFoundError, AttributeError):
pass