Skip to content

Commit

Permalink
Merge pull request #24 from ami-iit/feature/ci
Browse files Browse the repository at this point in the history
Add Continuous Integration test on parsing URDF models
  • Loading branch information
diegoferigo authored Jul 13, 2023
2 parents 4883caf + 4965a71 commit f229b82
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 4 deletions.
70 changes: 66 additions & 4 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,42 +50,104 @@ jobs:
name: dist

test:
name: 'Python${{ matrix.python }}@${{ matrix.os }}'
name: 'Python${{ matrix.python }}@${{ matrix.type }}@${{ matrix.os }}'
needs: package
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash -el {0}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
- macos-latest
- windows-latest
type:
- apt
- conda
python:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
exclude:
- os: macos-latest
type: apt
- os: windows-latest
type: apt

steps:

- name: Set up Python
if: matrix.type == 'apt'
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}

- uses: conda-incubator/setup-miniconda@v2
if: matrix.type == 'conda'
with:
python-version: ${{ matrix.python }}
miniforge-variant: Mambaforge
miniforge-version: latest
channels: conda-forge
channel-priority: true

- name: Install system dependencies
if: matrix.type == 'apt'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends gazebo
# Remove following line as soon as iDynTree is available on Python 3.11:
pip install --pre idyntree
- name: Install conda dependencies
if: matrix.type == 'conda'
run: |
mamba install -y \
coloredlogs \
mashumaro \
numpy \
packaging \
scipy \
xmltodict \
black \
isort \
pptree \
idyntree \
pytest \
robot_descriptions
# pytest-icdiff \ # creates problems on macOS
mamba install -y gz-sim7 idyntree
- name: Download Python packages
uses: actions/download-artifact@v3
with:
path: dist
name: dist

- name: Install wheel
shell: bash
run: pip install dist/*.whl
- name: Install wheel (apt)
if: matrix.type == 'apt'
run: pip install "$(find dist/ -type f -name '*.whl')[all]"

- name: Install wheel (conda)
if: matrix.type == 'conda'
run: pip install --no-deps "$(find dist/ -type f -name '*.whl')[all]"

- name: Pip check
run: pip check

- name: Import the package
run: python -c "import rod"

- uses: actions/checkout@v3
if: matrix.os != 'windows-latest'

- name: Run tests
if: matrix.os != 'windows-latest'
run: pytest

publish:
name: Publish to PyPI
needs: test
Expand Down
10 changes: 10 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,19 @@ style =
isort
pptree =
pptree
test =
idyntree
pytest
pytest-icdiff
robot-descriptions
all =
%(style)s
%(pptree)s
%(test)s

[options.packages.find]
where = src

[tool:pytest]
addopts = -rsxX -v --strict-markers
testpaths = tests
114 changes: 114 additions & 0 deletions tests/test_urdf_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import pytest
import robot_descriptions
import robot_descriptions.loaders.idyntree
from utils_models import ModelFactory, Robot

import rod


@pytest.mark.parametrize(
"robot",
[
Robot.iCub,
Robot.DoublePendulum,
Robot.Cassie,
Robot.Ur10,
Robot.AtlasV4,
Robot.Ergocub,
],
)
def test_urdf_parsing(robot: Robot) -> None:
"""Test parsing URDF files."""

# Get the path to the URDF
urdf_path = ModelFactory.get_model_description(robot=robot)

# Check that it fails if is_urdf=False and the resource is a path
with pytest.raises(RuntimeError):
_ = rod.Sdf.load(sdf=urdf_path, is_urdf=False)

# Check that it fails if is_urdf=False and the resource is a path string
with pytest.raises(RuntimeError):
_ = rod.Sdf.load(sdf=str(urdf_path), is_urdf=False)

# Check that it fails if is_urdf=False and the resource is an urdf string
with pytest.raises(RuntimeError):
_ = rod.Sdf.load(sdf=urdf_path.read_text(), is_urdf=False)

# Check that it fails if is_urdf=False and the resource is an urdf string
with pytest.raises(RuntimeError):
_ = rod.Sdf.load(sdf=urdf_path.read_text(), is_urdf=None)

# The following instead should succeed
_ = rod.Sdf.load(sdf=urdf_path, is_urdf=None)
_ = rod.Sdf.load(sdf=urdf_path, is_urdf=True)
_ = rod.Sdf.load(sdf=str(urdf_path), is_urdf=None)
_ = rod.Sdf.load(sdf=str(urdf_path), is_urdf=True)
_ = rod.Sdf.load(sdf=urdf_path.read_text(), is_urdf=True)

# Load once again the urdf
rod_sdf = rod.Sdf.load(sdf=urdf_path, is_urdf=True)

# There should be only one model
assert len(rod_sdf.models()) == 1
assert rod_sdf.models()[0] == rod_sdf.model

# Note: when a URDF is loaded into ROD, it gets first converted to SDF by sdformat.
# This pre-processing might alter the URDF, especially if fixed joints are present.

# Load the model in iDynTree (w/o specifying the joints list)
idt_model_full = robot_descriptions.loaders.idyntree.load_robot_description(
description_name=robot.to_module_name(), joints_list=None
)

# Check the canonical link
assert rod_sdf.model.get_canonical_link() == idt_model_full.getLinkName(
idt_model_full.getDefaultBaseLink()
)

# Extract data from the ROD model
link_names = [l.name for l in rod_sdf.model.links()]
joint_names = [j.name for j in rod_sdf.model.joints()]
joint_names_dofs = [j.name for j in rod_sdf.model.joints() if j.type != "fixed"]

# Remove the world joint if the model is fixed-base
if rod_sdf.model.is_fixed_base():
world_joints = [
j.name
for j in rod_sdf.model.joints()
if j.type == "fixed" and j.parent == "world"
]
assert len(world_joints) == 1
_ = joint_names.pop(joint_names.index(world_joints[0]))
assert _ == world_joints[0]

# New sdformat versions, after lumping fixed joints, create a new frame called
# "<removed_fake_link>_fixed_joint"
frame_names = [
f.name for f in rod_sdf.model.frames() if not f.name.endswith("_fixed_joint")
]

# Check the number of DoFs (all non-fixed joints)
assert len(joint_names_dofs) == idt_model_full.getNrOfDOFs()

link_names_idt = [
idt_model_full.getLinkName(idx) for idx in range(idt_model_full.getNrOfLinks())
]

joint_names_idt = [
idt_model_full.getJointName(idx)
for idx in range(idt_model_full.getNrOfJoints())
]

# Note: iDynTree also includes sensor frames here, while ROD does not
frame_names_idt = [
idt_model_full.getFrameName(idx)
for idx in range(idt_model_full.getNrOfLinks(), idt_model_full.getNrOfFrames())
]

assert set(link_names_idt) == set(link_names)
assert set(joint_names_idt) == set(joint_names)
assert set(frame_names) - set(frame_names_idt) == set()

total_mass = sum([l.inertial.mass for l in rod_sdf.model.links()])
assert total_mass == pytest.approx(idt_model_full.getTotalMass())
60 changes: 60 additions & 0 deletions tests/utils_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import enum
import importlib
import pathlib
from typing import Union


class Robot(enum.IntEnum):
Hyq = enum.auto()
iCub = enum.auto()
Ur10 = enum.auto()
Baxter = enum.auto()
Cassie = enum.auto()
Fetch = enum.auto()
AtlasV4 = enum.auto()
Laikago = enum.auto()
AnymalC = enum.auto()
Ergocub = enum.auto()
Talos = enum.auto()
Valkyrie = enum.auto()
DoublePendulum = enum.auto()
SimpleHumanoid = enum.auto()

def to_module_name(self) -> str:
""""""

if self is Robot.iCub:
return "icub_description"

# Convert the camelcase robot name to snakecase
name_sc = "".join(
["_" + c.lower() if c.isupper() else c for c in self.name]
).lstrip("_")

return f"{name_sc}_description"


class ModelFactory:
"""Factory class providing URDF files used by the tests."""

@staticmethod
def get_model_description(robot: Union[Robot, str]) -> pathlib.Path:
"""
Get the URDF file of different robots.
Args:
robot: Robot name of the desired URDF file.
Returns:
Path to the URDF file of the robot.
"""

module_name = robot if isinstance(robot, str) else robot.to_module_name()
module = importlib.import_module(f"robot_descriptions.{module_name}")

urdf_path = pathlib.Path(module.URDF_PATH)

if not urdf_path.is_file():
raise FileExistsError(f"URDF file '{urdf_path}' does not exist")

return urdf_path

0 comments on commit f229b82

Please sign in to comment.