From 22167567e18f4147dad4d2904c63b7207455c755 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 18:40:13 +0200 Subject: [PATCH 01/15] New model, physics, and task randomizers base classes --- python/gym_ignition/randomizers/__init__.py | 5 ++ .../gym_ignition/randomizers/base/__init__.py | 7 ++ python/gym_ignition/randomizers/base/model.py | 27 +++++++ .../gym_ignition/randomizers/base/physics.py | 72 +++++++++++++++++++ python/gym_ignition/randomizers/base/task.py | 38 ++++++++++ 5 files changed, 149 insertions(+) create mode 100644 python/gym_ignition/randomizers/__init__.py create mode 100644 python/gym_ignition/randomizers/base/__init__.py create mode 100644 python/gym_ignition/randomizers/base/model.py create mode 100644 python/gym_ignition/randomizers/base/physics.py create mode 100644 python/gym_ignition/randomizers/base/task.py diff --git a/python/gym_ignition/randomizers/__init__.py b/python/gym_ignition/randomizers/__init__.py new file mode 100644 index 000000000..62818dc04 --- /dev/null +++ b/python/gym_ignition/randomizers/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from . import base diff --git a/python/gym_ignition/randomizers/base/__init__.py b/python/gym_ignition/randomizers/base/__init__.py new file mode 100644 index 000000000..2bd832cd3 --- /dev/null +++ b/python/gym_ignition/randomizers/base/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from . import task +from . import model +from . import physics diff --git a/python/gym_ignition/randomizers/base/model.py b/python/gym_ignition/randomizers/base/model.py new file mode 100644 index 000000000..49f823376 --- /dev/null +++ b/python/gym_ignition/randomizers/base/model.py @@ -0,0 +1,27 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import abc + + +class ModelRandomizer(abc.ABC): + + @abc.abstractmethod + def randomize_model(self) -> str: + """ + Randomize the model. + + Return: + A string with the randomized model. + """ + pass + + def seed_model_randomizer(self, seed: int) -> None: + """ + Seed the randomizer to ensure reproducibility. + + Args: + seed: The seed number. + """ + pass diff --git a/python/gym_ignition/randomizers/base/physics.py b/python/gym_ignition/randomizers/base/physics.py new file mode 100644 index 000000000..177bb2e07 --- /dev/null +++ b/python/gym_ignition/randomizers/base/physics.py @@ -0,0 +1,72 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import abc +from gym_ignition import scenario_bindings as bindings + + +class PhysicsRandomizer(abc.ABC): + """ + Abstract class that provides the machinery for randomizing physics in a Ignition + Gazebo simulation. + + Args: + randomize_after_rollouts_num: defines after many rollouts physics should be + randomized (i.e. the amount of times :py:meth:`gym.Env.reset` is called). + """ + + def __init__(self, randomize_after_rollouts_num: int = 0): + + self._rollout_counter = randomize_after_rollouts_num + self.randomize_after_rollouts_num = randomize_after_rollouts_num + + @abc.abstractmethod + def randomize_physics(self, world: bindings.World) -> None: + """ + Method that insert and configures the physics of a world. + + By default this method loads a plugin that uses DART with no randomizations. + Randomizing physics engine parameters or changing physics engine backend could be + done by redefining this method and passing it to + :py:class:`~gym_ignition.runtimes.gazebo_runtime.GazeboRuntime`. + + Args: + world: A world object without physics. + """ + pass + + def seed_physics_randomizer(self, seed: int) -> None: + """ + Seed the randomizer to ensure reproducibility. + + Args: + seed: The seed number. + """ + pass + + def add_rollout_to_physics(self) -> None: + """ + Increase the rollouts counter. + """ + + if self.randomize_after_rollouts_num != 0: + assert self._rollout_counter != 0 + self._rollout_counter -= 1 + + def physics_expired(self) -> bool: + """ + Checks if the physics needs to be randomized. + + Return: + True if the physics has expired, false otherwise. + """ + + if self.randomize_after_rollouts_num == 0: + return False + + if self._rollout_counter == 0: + self._rollout_counter = self.randomize_after_rollouts_num + return True + + return False diff --git a/python/gym_ignition/randomizers/base/task.py b/python/gym_ignition/randomizers/base/task.py new file mode 100644 index 000000000..c12b27b81 --- /dev/null +++ b/python/gym_ignition/randomizers/base/task.py @@ -0,0 +1,38 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import abc +from gym_ignition import base +from gym_ignition import scenario_bindings as bindings + + +class TaskRandomizer(abc.ABC): + + @abc.abstractmethod + def randomize_task(self, + task: base.task.Task, + gazebo: bindings.GazeboSimulator, + **kwargs) -> None: + """ + Randomize a :py:class:`~gym_ignition.base.task.Task` instance. + + Args: + task: the task to randomize. + gazebo: a :py:class:`~scenario_bindings.GazeboSimulator` instance. + + Note: + Note the each task has a :py:attr:`~gym_ignition.base.task.Task.world` + property that provides access to the simulated + :py:class:`scenario_bindings.World`. + """ + pass + + def seed_task_randomizer(self, seed: int) -> None: + """ + Seed the randomizer to ensure reproducibility. + + Args: + seed: The seed number. + """ + pass From 6789a9cc78bfded4830ce06eec313c788a83b1b5 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 12:43:25 +0200 Subject: [PATCH 02/15] New SDFRandomizer class --- python/gym_ignition/randomizers/__init__.py | 1 + .../randomizers/model/__init__.py | 5 + python/gym_ignition/randomizers/model/sdf.py | 235 ++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 python/gym_ignition/randomizers/model/__init__.py create mode 100644 python/gym_ignition/randomizers/model/sdf.py diff --git a/python/gym_ignition/randomizers/__init__.py b/python/gym_ignition/randomizers/__init__.py index 62818dc04..cac51bb22 100644 --- a/python/gym_ignition/randomizers/__init__.py +++ b/python/gym_ignition/randomizers/__init__.py @@ -3,3 +3,4 @@ # GNU Lesser General Public License v2.1 or any later version. from . import base +from . import model diff --git a/python/gym_ignition/randomizers/model/__init__.py b/python/gym_ignition/randomizers/model/__init__.py new file mode 100644 index 000000000..ff6f7523f --- /dev/null +++ b/python/gym_ignition/randomizers/model/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from . import sdf diff --git a/python/gym_ignition/randomizers/model/sdf.py b/python/gym_ignition/randomizers/model/sdf.py new file mode 100644 index 000000000..0bc544478 --- /dev/null +++ b/python/gym_ignition/randomizers/model/sdf.py @@ -0,0 +1,235 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import numpy as np +from lxml import etree +from pathlib import Path +from enum import auto, Enum +from typing import Dict, List, NamedTuple, Union + + +class Distribution(Enum): + Uniform = auto() + Gaussian = auto() + + +class Method(Enum): + Absolute = auto() + Additive = auto() + Coefficient = auto() + + +class RandomizationData(NamedTuple): + xpath: str + distribution: str + parameters: "DistributionParameters" + method: Method + ignore_zeros: bool = False + force_positive: bool = False + element: etree.Element = None + + +class GaussianParams(NamedTuple): + variance: float + mean: float = None + + +class UniformParams(NamedTuple): + low: float + high: float + + +DistributionParameters = Union[UniformParams, GaussianParams] + + +class RandomizationDataBuilder: + + def __init__(self, randomizer: "SDFRandomizer"): + + self.storage: Dict = {} + self.randomizer = randomizer + + def at_xpath(self, xpath: str) -> "RandomizationDataBuilder": + self.storage["xpath"] = xpath + return self + + def sampled_from(self, + distribution: Distribution, + parameters: DistributionParameters) -> "RandomizationDataBuilder": + + self.storage["distribution"] = distribution + self.storage["parameters"] = parameters + + if self.storage["distribution"] is Distribution.Gaussian and \ + not isinstance(parameters, GaussianParams): + raise ValueError("Wrong parameters type") + + if self.storage["distribution"] is Distribution.Uniform and \ + not isinstance(parameters, UniformParams): + raise ValueError("Wrong parameters type") + + return self + + def method(self, method: Method) -> "RandomizationDataBuilder": + + self.storage["method"] = method + return self + + def ignore_zeros(self, ignore_zeros: bool) -> "RandomizationDataBuilder": + self.storage["ignore_zeros"] = ignore_zeros + return self + + def force_positive(self, force_positive: bool = True) -> "RandomizationDataBuilder": + self.storage["force_positive"] = force_positive + return self + + def add(self) -> None: + + data = RandomizationData(**self.storage) + + if len(self.randomizer.find_xpath(data.xpath)) == 0: + raise RuntimeError(f"Failed to find element matching XPath '{data.xpath}'") + + self.randomizer.insert(randomization_data=data) + + +class SDFRandomizer: + + def __init__(self, sdf_model: str): + + self._sdf_file = sdf_model + + if not Path(self._sdf_file).is_file(): + raise ValueError(f"File '{sdf_model}' does not exist") + + # Initialize the root + tree = self._get_tree_from_file(self._sdf_file) + self._root: etree.Element = tree.getroot() + + # List of randomizations + self._randomizations: List[RandomizationData] = [] + + # List of default values used with Method.Coefficient + self._default_values: Dict[etree.Element, float] = {} + + # Store an independent RNG + self.rng = np.random.default_rng() + + def seed(self, seed: int) -> None: + self.rng = np.random.default_rng(seed) + + def find_xpath(self, xpath: str) -> List[etree.Element]: + return self._root.findall(xpath) + + def process_data(self) -> None: + + # Since we support multi-match XPaths, we expand all the individual matches + expanded_randomizations = [] + + for data in self._randomizations: + + # Find all the matches + elements: List[etree.Element] = self._root.findall(path=data.xpath) + + if len(elements) == 0: + raise RuntimeError(f"Failed to find elements from XPath '{data.xpath}'") + + for element in elements: + + if data.ignore_zeros and float(self._get_element_text(element)) == 0: + continue + + # Get the precise XPath to the element + element_xpath = element.getroottree().getpath(element) + + # Get the parameters + params = data.parameters + + if data.method in {Method.Additive, Method.Coefficient}: + element_text = float(self._get_element_text(element)) + self._default_values[element] = element_text + + # Update the data + complete_data = data._replace( + xpath=element_xpath, element=element, parameters=params) + + expanded_randomizations.append(complete_data) + + # Store the updated data + self._randomizations = expanded_randomizations + + def sample(self, pretty_print=False) -> str: + + for data in self._randomizations: + + if data.distribution is Distribution.Gaussian: + + sample = self.rng.normal(loc=data.parameters.mean, + scale=data.parameters.variance) + + elif data.distribution is Distribution.Uniform: + + sample = self.rng.uniform(low=data.parameters.low, + high=data.parameters.high) + + else: + raise ValueError("Distribution not recognized") + + if data.force_positive: + sample = max(sample, 0.0) + + # Update the value + if data.method is Method.Absolute: + + data.element.text = str(sample) + + elif data.method is Method.Additive: + + default_value = self._default_values[data.element] + data.element.text = str(sample + default_value) + + elif data.method is Method.Coefficient: + + default_value = self._default_values[data.element] + data.element.text = str(sample * default_value) + + else: + raise ValueError("Method not recognized") + + return etree.tostring(self._root, pretty_print=pretty_print).decode() + + def new_randomization(self) -> RandomizationDataBuilder: + return RandomizationDataBuilder(randomizer=self) + + def insert(self, randomization_data) -> None: + self._randomizations.append(randomization_data) + + def get_active_randomizations(self) -> List[RandomizationData]: + return self._randomizations + + def clean(self) -> None: + + self._randomizations = [] + self._default_values = {} + + tree = self._get_tree_from_file(self._sdf_file) + self._root = tree.getroot() + + @staticmethod + def _get_tree_from_file(xml_file) -> etree.ElementTree: + + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(source=xml_file, parser=parser) + + return tree + + @staticmethod + def _get_element_text(element: etree.Element) -> str: + + text = element.text + + if text is None: + raise RuntimeError(f"The element {element.tag} does not have any content") + + return text From 00416a974855973c10b403d015bf913a3085e270 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 12:44:39 +0200 Subject: [PATCH 03/15] New DART physics dummy randomizer --- python/gym_ignition/randomizers/__init__.py | 1 + .../randomizers/physics/__init__.py | 5 ++++ .../gym_ignition/randomizers/physics/dart.py | 29 +++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 python/gym_ignition/randomizers/physics/__init__.py create mode 100644 python/gym_ignition/randomizers/physics/dart.py diff --git a/python/gym_ignition/randomizers/__init__.py b/python/gym_ignition/randomizers/__init__.py index cac51bb22..1e95f85d9 100644 --- a/python/gym_ignition/randomizers/__init__.py +++ b/python/gym_ignition/randomizers/__init__.py @@ -4,3 +4,4 @@ from . import base from . import model +from . import physics diff --git a/python/gym_ignition/randomizers/physics/__init__.py b/python/gym_ignition/randomizers/physics/__init__.py new file mode 100644 index 000000000..83e9427c5 --- /dev/null +++ b/python/gym_ignition/randomizers/physics/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from . import dart diff --git a/python/gym_ignition/randomizers/physics/dart.py b/python/gym_ignition/randomizers/physics/dart.py new file mode 100644 index 000000000..09aabdd38 --- /dev/null +++ b/python/gym_ignition/randomizers/physics/dart.py @@ -0,0 +1,29 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from gym_ignition.randomizers.base import physics +from gym_ignition import scenario_bindings as bindings + + +class DART(physics.PhysicsRandomizer): + """ + Class that configures the Ignition Gazebo physics with the DART physics engine and + no randomization. + """ + + def __init__(self, seed: int = None): + + super().__init__() + + if seed is not None: + self.seed_physics_randomizer(seed=seed) + + def randomize_physics(self, world: bindings.World) -> None: + + # Insert the physics + ok_physics = world.insertWorldPlugin("libPhysicsSystem.so", + "scenario::plugins::gazebo::Physics") + + if not ok_physics: + raise RuntimeError("Failed to insert the physics plugin") From 64d954ab3c75ef7ff69a016789b0c5d23d135426 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 17:25:41 +0200 Subject: [PATCH 04/15] Use DART randomizer in GazeboRuntime --- .../gym_ignition/runtimes/gazebo_runtime.py | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/python/gym_ignition/runtimes/gazebo_runtime.py b/python/gym_ignition/runtimes/gazebo_runtime.py index 029c72478..b4d9e82bd 100644 --- a/python/gym_ignition/runtimes/gazebo_runtime.py +++ b/python/gym_ignition/runtimes/gazebo_runtime.py @@ -8,6 +8,8 @@ from gym_ignition.utils import logger from gym_ignition.utils.typing import * from gym_ignition.utils import scenario +from gym_ignition.randomizers.base import physics +from gym_ignition.randomizers.physics import dart from gym_ignition import scenario_bindings as bindings @@ -21,8 +23,15 @@ class GazeboRuntime(runtime.Runtime): agent_rate: The rate at which the environment is called. physics_rate: The rate of the physics engine. real_time_factor: The desired RTF of the simulation. - world (optional): The path to an SDF world file. The world should not contain any + physics_randomizer: *(optional)* The physics randomizer. + world: *(optional)* The path to an SDF world file. The world should not contain any physics plugin. + + Note: + Physics randomization is still experimental and it could change in the future. + Physics is loaded only once, when the simulator starts. In order to change the + physics, a new simulator should be created. This operation is quite demanding + and doing it every rollout is not recommended. """ metadata = {'render.modes': ['human']} @@ -32,6 +41,7 @@ def __init__(self, agent_rate: float, physics_rate: float, real_time_factor: float, + physics_randomizer: physics.PhysicsRandomizer = dart.DART(), world: str = None, **kwargs): @@ -43,6 +53,9 @@ def __init__(self, self._physics_rate = physics_rate self._real_time_factor = real_time_factor + # Store the randomizer + self.physics_randomizer = physics_randomizer + # World attributes self._world = None self._world_sdf = world @@ -248,27 +261,10 @@ def world(self) -> bindings.World: if not ok_ground: raise RuntimeError("Failed to insert the ground plane") - # Load the physics - self._load_physics(world) + # Load and randomize the physics + self.physics_randomizer.randomize_physics(world=world) # Store the world self._world = world return self._world - - @staticmethod - def _load_physics(world: bindings.World) -> None: - """ - Load the physics in the world. - - Note: - This class do not yet supports randomizing the physics. Likely overriding this - method will enable the implementation of physics randomization. - """ - - # Insert the physics - ok_physics = world.insertWorldPlugin("libPhysicsSystem.so", - "scenario::plugins::gazebo::Physics") - - if not ok_physics: - raise RuntimeError("Failed to insert the physics plugin") From e4f89a48a60f5d3028deeabe8d598144ab092e8f Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 17:14:19 +0200 Subject: [PATCH 05/15] New GazeboEnvRandomizer --- python/gym_ignition/randomizers/__init__.py | 2 + .../randomizers/gazebo_env_randomizer.py | 144 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 python/gym_ignition/randomizers/gazebo_env_randomizer.py diff --git a/python/gym_ignition/randomizers/__init__.py b/python/gym_ignition/randomizers/__init__.py index 1e95f85d9..7748a9a43 100644 --- a/python/gym_ignition/randomizers/__init__.py +++ b/python/gym_ignition/randomizers/__init__.py @@ -5,3 +5,5 @@ from . import base from . import model from . import physics + +from . import gazebo_env_randomizer diff --git a/python/gym_ignition/randomizers/gazebo_env_randomizer.py b/python/gym_ignition/randomizers/gazebo_env_randomizer.py new file mode 100644 index 000000000..a81eb2d07 --- /dev/null +++ b/python/gym_ignition/randomizers/gazebo_env_randomizer.py @@ -0,0 +1,144 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import abc +import gym +from typing import cast +from gym_ignition import randomizers +from gym_ignition.utils import logger, typing +from gym_ignition.runtimes import gazebo_runtime +from gym_ignition.randomizers.base import physics +from gym_ignition.randomizers.physics import dart +from typing import Callable, Dict, Optional, Union + +MakeEnvCallable = Callable[[Optional[Dict]],gym.Env] + + +class GazeboEnvRandomizer(gym.Wrapper, + randomizers.base.task.TaskRandomizer, + abc.ABC): + """ + Base class to implement an environment randomizer for Ignition Gazebo. + + The randomizer is a :py:class:`gym.Wrapper` that extends the + :py:meth:`gym.Env.reset` method. Objects that inherit from this class are used to + setup the environment for the handled :py:class:`~gym_ignition.base.task.Task`. + + In its simplest form, a randomizer populates the world with all the models that need + to be part of the simulation. The task could then operate on them from a + :py:class:`~scenario_bindings.Model` object. + + More complex environments may require to randomize one or more simulated entities. + Concrete classes that implement a randomizer could use + :py:class:`~gym_ignition.randomizers.model.sdf.SDFRandomizer` to randomize the model + and objects inheriting from + :py:class:`~gym_ignition.randomizers.base.physics.PhysicsRandomizer` to randomize the + physics. + + Args: + env: Defines the environment to handle. This argument could be either the string + id if the environment does not need to be registered or a function that + returns an environment object. + physics_randomizer: Object that randomizes physics. The default physics engine is + DART with no randomizations. + + Note: + In order to randomize physics, the handled + :py:class:`scenario_bindings.GazeboSimulator` is destroyed and created again. + This operation is demanding, consider randomizing physics at a low rate. + + Todo: + Allow resetting the physics by removing and inserting the world. + """ + + def __init__(self, + env: Union[str, MakeEnvCallable], + physics_randomizer: physics.PhysicsRandomizer = dart.DART(), + **kwargs): + + # Store the options + self._env_option = env + self._kwargs = dict(**kwargs, physics_randomizer=physics_randomizer) + + # Create the environment + env_to_wrap = self._create_environment(env=self._env_option, **self._kwargs) + + # Initialize the wrapper + gym.Wrapper.__init__(self, env=env_to_wrap) + + # =============== + # gym.Env methods + # =============== + + def reset(self, **kwargs) -> typing.Observation: + + # Reset the physics + if self.env.physics_randomizer.physics_expired(): + + # Get the random components of the task + seed = self.env.task.seed + np_random = self.env.task.np_random + + # Reset the runtime + task, creating a new Gazebo instance + self.env.close() + del self.env + self.env = self._create_environment(self._env_option, **self._kwargs) + + # Restore the random components + self.env.seed(seed=seed) + assert self.env.task.seed == seed + self.env.task.np_random = np_random + + # Mark the beginning of a new rollout + self.env.physics_randomizer.add_rollout_to_physics() + + # Reset the task through the TaskRandomizer + self.randomize_task(self.env.task, self.env.gazebo, **kwargs) + + ok_paused_run = self.env.gazebo.run(paused=True) + + if not ok_paused_run: + raise RuntimeError("Failed to execute a paused Gazebo run") + + # Reset the Task + return self.env.reset() + + # =============== + # Private methods + # =============== + + def _create_environment(self, + env: Union[str, MakeEnvCallable], + **kwargs) -> gazebo_runtime.GazeboRuntime: + + if isinstance(env, str): + env_to_wrap = self._create_from_id(env_id=env, **kwargs) + + elif callable(env): + env_to_wrap = self._create_from_callable(make_env=env, **kwargs) + + else: + raise ValueError("The type of env object was not recognized") + + if not isinstance(env_to_wrap.unwrapped, gazebo_runtime.GazeboRuntime): + raise ValueError("The environment to wrap is not a GazeboRuntime") + + return cast(gazebo_runtime.GazeboRuntime, env_to_wrap) + + @staticmethod + def _create_from_callable(make_env: MakeEnvCallable, + **kwargs) -> gym.Env: + + with logger.verbosity(level=gym.logger.WARN): + env = make_env(**kwargs) + + return env + + @staticmethod + def _create_from_id(env_id: str, **kwargs) -> gym.Env: + + with logger.verbosity(level=gym.logger.WARN): + env = gym.make(env_id, **kwargs) + + return env From 0015a94b83ae9582562229d05fd570fe496d6cd9 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 12:48:41 +0200 Subject: [PATCH 06/15] Register demo environments of gym_ignition_environments --- python/gym_ignition_environments/__init__.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 python/gym_ignition_environments/__init__.py diff --git a/python/gym_ignition_environments/__init__.py b/python/gym_ignition_environments/__init__.py new file mode 100644 index 000000000..1115b8659 --- /dev/null +++ b/python/gym_ignition_environments/__init__.py @@ -0,0 +1,52 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import numpy +from .tasks import pendulum_swingup +from gym.envs.registration import register +from .tasks import cartpole_discrete_balancing +from .tasks import cartpole_continuous_swingup +from .tasks import cartpole_continuous_balancing + +max_float = float(numpy.finfo(numpy.float32).max) + +register( + id='Pendulum-Gazebo-v0', + entry_point='gym_ignition.runtimes.gazebo_runtime:GazeboRuntime', + max_episode_steps=5000, + kwargs={'task_cls': pendulum_swingup.PendulumSwingUp, + 'agent_rate': 1000, + 'physics_rate': 1000, + 'real_time_factor': max_float, + }) + +register( + id='CartPoleDiscreteBalancing-Gazebo-v0', + entry_point='gym_ignition.runtimes.gazebo_runtime:GazeboRuntime', + max_episode_steps=5000, + kwargs={'task_cls': cartpole_discrete_balancing.CartPoleDiscreteBalancing, + 'agent_rate': 1000, + 'physics_rate': 1000, + 'real_time_factor': max_float, + }) + +register( + id='CartPoleContinuousBalancing-Gazebo-v0', + entry_point='gym_ignition.runtimes.gazebo_runtime:GazeboRuntime', + max_episode_steps=5000, + kwargs={'task_cls': cartpole_continuous_balancing.CartPoleContinuousBalancing, + 'agent_rate': 1000, + 'physics_rate': 1000, + 'real_time_factor': max_float, + }) + +register( + id='CartPoleContinuousSwingup-Gazebo-v0', + entry_point='gym_ignition.runtimes.gazebo_runtime:GazeboRuntime', + max_episode_steps=5000, + kwargs={'task_cls': cartpole_continuous_swingup.CartPoleContinuousSwingup, + 'agent_rate': 1000, + 'physics_rate': 1000, + 'real_time_factor': max_float, + }) From fdef53ce9c2596872be98cc4ab603746319d4b3a Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 12:49:52 +0200 Subject: [PATCH 07/15] Add lxml dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 5db6c7cec..c5c467ace 100644 --- a/setup.py +++ b/setup.py @@ -147,6 +147,7 @@ def build_extension(self, ext): 'gym >= 0.13.1', 'numpy', 'gym_ignition_models', + 'lxml', ], packages=find_packages("python"), package_dir={'': "python"}, From c2b74a2ff8f8215e34f96dd21ff377b4a2470df5 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 17:16:37 +0200 Subject: [PATCH 08/15] New environment randomizers --- .../randomizers/__init__.py | 6 + .../randomizers/cartpole.py | 211 ++++++++++++++++++ .../randomizers/cartpole_no_rand.py | 68 ++++++ 3 files changed, 285 insertions(+) create mode 100644 python/gym_ignition_environments/randomizers/__init__.py create mode 100644 python/gym_ignition_environments/randomizers/cartpole.py create mode 100644 python/gym_ignition_environments/randomizers/cartpole_no_rand.py diff --git a/python/gym_ignition_environments/randomizers/__init__.py b/python/gym_ignition_environments/randomizers/__init__.py new file mode 100644 index 000000000..c96f81343 --- /dev/null +++ b/python/gym_ignition_environments/randomizers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from . import cartpole +from . import cartpole_no_rand diff --git a/python/gym_ignition_environments/randomizers/cartpole.py b/python/gym_ignition_environments/randomizers/cartpole.py new file mode 100644 index 000000000..cf9109a4e --- /dev/null +++ b/python/gym_ignition_environments/randomizers/cartpole.py @@ -0,0 +1,211 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import abc +import numpy as np +from typing import Union +from gym_ignition import utils +from gym_ignition.utils import misc +from gym_ignition import randomizers +from gym_ignition_environments import tasks +from gym_ignition_environments.models import cartpole +from gym_ignition import scenario_bindings as bindings +from gym_ignition.randomizers import gazebo_env_randomizer +from gym_ignition.randomizers.gazebo_env_randomizer import MakeEnvCallable +from gym_ignition.randomizers.model.sdf import Method, Distribution, UniformParams + +# Tasks that are supported by this randomizer. Used for type hinting. +SupportedTasks = Union[tasks.cartpole_discrete_balancing.CartPoleDiscreteBalancing, + tasks.cartpole_continuous_swingup.CartPoleContinuousSwingup, + tasks.cartpole_continuous_balancing.CartPoleContinuousBalancing] + + +class CartpoleRandomizersMixin(randomizers.base.task.TaskRandomizer, + randomizers.base.model.ModelRandomizer, + randomizers.base.physics.PhysicsRandomizer, + abc.ABC): + """ + Mixin that collects the implementation of task, model and physics randomizations for + cartpole environments. + """ + + def __init__(self, + seed: int = None, + randomize_physics_after_rollouts: int = 0): + + # Initialize the randomizers + super().__init__(randomize_after_rollouts_num=randomize_physics_after_rollouts) + + # SDF randomizer + self._sdf_randomizer = None + + # Seed the RNG + np_random = np.random.default_rng(seed=seed) + + # Store the seed and use the same RNG for all the randomizers + self._seed = seed + self.np_random_task = np_random # Unused + self.np_random_physics = np_random + self._get_sdf_randomizer().seed(seed=self._seed) + + # =========================== + # PhysicsRandomizer interface + # =========================== + + def seed_physics_randomizer(self, seed: int) -> None: + + if seed == self._seed: + return + + self.np_random_physics = np.random.default_rng(seed=self._seed) + + def randomize_physics(self, world: bindings.World) -> None: + + ok_physics = world.insertWorldPlugin("libPhysicsSystem.so", + "scenario::plugins::gazebo::Physics") + + if not ok_physics: + raise RuntimeError("Failed to insert the physics plugin") + + gravity_z = self.np_random_physics.normal(loc=-9.8, scale=0.2) + ok_gravity = world.setGravity([0, 0, gravity_z]) + + if not ok_gravity: + raise RuntimeError("Failed to set the gravity") + + # ======================== + # TaskRandomizer interface + # ======================== + + def seed_task_randomizer(self, seed: int) -> None: + + if seed == self._seed: + return + + self.np_random_task = np.random.default_rng(seed=self._seed) + + def randomize_task(self, + task: SupportedTasks, + gazebo: bindings.GazeboSimulator, + **kwargs) -> None: + + # Remove the model from the world + self._clean_world(task=task) + + # Execute a paused run to process model removal + ok_paused_run = gazebo.run(paused=True) + + if not ok_paused_run: + raise RuntimeError("Failed to execute a paused Gazebo run") + + # Generate a random model + random_model = self.randomize_model() + + # Insert a new model in the world + self._populate_world(task=task, cartpole_model=random_model) + + # Execute a paused run to process model insertion + ok_paused_run = gazebo.run(paused=True) + + if not ok_paused_run: + raise RuntimeError("Failed to execute a paused Gazebo run") + + # ========================= + # ModelRandomizer interface + # ========================= + + def seed_model_randomizer(self, seed: int) -> None: + + if seed == self._seed: + return + + self._get_sdf_randomizer().seed(seed=self._seed) + + def randomize_model(self) -> str: + + randomizer = self._get_sdf_randomizer() + sdf = misc.string_to_file(randomizer.sample()) + return sdf + + # =============== + # Private Methods + # =============== + + def _get_sdf_randomizer(self) -> randomizers.model.sdf.SDFRandomizer: + + if self._sdf_randomizer is not None: + return self._sdf_randomizer + + # Get the model file + urdf_model_file = cartpole.CartPole.get_model_file() + + # Convert the URDF to SDF + sdf_model_string = bindings.URDFFileToSDFString(urdf_model_file) + + # Write the SDF string to a temp file + sdf_model = utils.misc.string_to_file(sdf_model_string) + + # Create and initialize the randomizer + sdf_randomizer = randomizers.model.sdf.SDFRandomizer(sdf_model=sdf_model) + + # Seed the randomizer + sdf_randomizer.seed(self._seed) + + # Randomize the mass of all links + sdf_randomizer.new_randomization() \ + .at_xpath("*/link/inertial/mass") \ + .method(Method.Additive) \ + .sampled_from(Distribution.Uniform, UniformParams(low=-0.2, high=0.2)) \ + .force_positive() \ + .add() + + # Process the randomization + sdf_randomizer.process_data() + assert len(sdf_randomizer.get_active_randomizations()) > 0 + + # Store and return the randomizer + self._sdf_randomizer = sdf_randomizer + return self._sdf_randomizer + + @staticmethod + def _clean_world(task: SupportedTasks): + + # Remove the model from the simulation + if task.model_name is not None and task.model_name in task.world.modelNames(): + + ok_removed = task.world.removeModel(task.model_name) + + if not ok_removed: + raise RuntimeError("Failed to remove the cartpole from the world") + + @staticmethod + def _populate_world(task: SupportedTasks, cartpole_model: str = None) -> None: + + # Insert a new cartpole. + # It will create a unique name if there are clashing. + model = cartpole.CartPole(world=task.world, + model_file=cartpole_model) + + # Store the model name in the task + task.model_name = model.name() + + +class CartpoleEnvRandomizer(gazebo_env_randomizer.GazeboEnvRandomizer, + CartpoleRandomizersMixin): + """ + Concrete implementation of cartpole environments randomization. + """ + + def __init__(self, + env: MakeEnvCallable, + seed: int = None, + num_physics_rollouts: int = 0): + + # Initialize the mixin + CartpoleRandomizersMixin.__init__( + self, seed=seed, randomize_physics_after_rollouts=num_physics_rollouts) + + # Initialize the environment randomizer + gazebo_env_randomizer.GazeboEnvRandomizer.__init__( + self, env=env, physics_randomizer=self) diff --git a/python/gym_ignition_environments/randomizers/cartpole_no_rand.py b/python/gym_ignition_environments/randomizers/cartpole_no_rand.py new file mode 100644 index 000000000..46a248d07 --- /dev/null +++ b/python/gym_ignition_environments/randomizers/cartpole_no_rand.py @@ -0,0 +1,68 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +from typing import Union +from gym_ignition_environments import tasks +from gym_ignition_environments.models import cartpole +from gym_ignition import scenario_bindings as bindings +from gym_ignition.randomizers import gazebo_env_randomizer +from gym_ignition.randomizers.gazebo_env_randomizer import MakeEnvCallable + +# Tasks that are supported by this randomizer. Used for type hinting. +SupportedTasks = Union[tasks.cartpole_discrete_balancing.CartPoleDiscreteBalancing, + tasks.cartpole_continuous_swingup.CartPoleContinuousSwingup, + tasks.cartpole_continuous_balancing.CartPoleContinuousBalancing] + + +class CartpoleEnvNoRandomizations(gazebo_env_randomizer.GazeboEnvRandomizer): + """ + Dummy environment randomizer for cartpole tasks. + + Check :py:class:`~gym_ignition_environments.randomizers.cartpole.CartpoleRandomizersMixin` + for an example that randomizes the task, the physics, and the model. + """ + + def __init__(self, env: MakeEnvCallable): + + super().__init__(env=env) + + def randomize_task(self, + task: SupportedTasks, + gazebo: bindings.GazeboSimulator, + **kwargs) -> None: + """ + Prepare the scene for cartpole tasks. It simply removes the cartpole of the + previous rollout and inserts a new one in the default state. Then, the active + Task will reset the state of the cartpole depending on the implemented + decision-making logic. + """ + + # Remove the model from the simulation + if task.model_name is not None and task.model_name in task.world.modelNames(): + + ok_removed = task.world.removeModel(task.model_name) + + if not ok_removed: + raise RuntimeError("Failed to remove the cartpole from the world") + + # Execute a paused run to process model removal + ok_paused_run = gazebo.run(paused=True) + + if not ok_paused_run: + raise RuntimeError("Failed to execute a paused Gazebo run") + + # Insert a new cartpole model + model = cartpole.CartPole(world=task.world) + + # Store the model name in the task + task.model_name = model.name() + + # Execute a paused run to process model insertion + ok_paused_run = gazebo.run(paused=True) + + if not ok_paused_run: + raise RuntimeError("Failed to execute a paused Gazebo run") + + def seed_task_randomizer(self, seed: int) -> None: + pass From b5673c9867479c08d60d4bb9425e332a88d38c30 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 14:44:40 +0200 Subject: [PATCH 09/15] Add URDF to SDF conversion helpers --- .../gazebo/include/scenario/gazebo/utils.h | 18 +++++++++++++++ cpp/scenario/gazebo/src/utils.cpp | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cpp/scenario/gazebo/include/scenario/gazebo/utils.h b/cpp/scenario/gazebo/include/scenario/gazebo/utils.h index f17896672..74e297dfd 100644 --- a/cpp/scenario/gazebo/include/scenario/gazebo/utils.h +++ b/cpp/scenario/gazebo/include/scenario/gazebo/utils.h @@ -162,6 +162,24 @@ namespace scenario { * installed in Developer mode, an empty string otherwise. */ std::string getInstallPrefix(); + + /** + * Convert a URDF file to a SDF string. + * + * @param urdfFile The absolute path to the URDF file. + * @return The SDF string if the file exists and it was successfully + * converted, an empty string otherwise. + */ + std::string URDFFileToSDFString(const std::string& urdfFile); + + /** + * Convert a URDF string to a SDF string. + * + * @param urdfFile A URDF string. + * @return The SDF string if the URDF string was successfully + * converted, an empty string otherwise. + */ + std::string URDFStringToSDFString(const std::string& urdfString); } // namespace utils } // namespace gazebo } // namespace scenario diff --git a/cpp/scenario/gazebo/src/utils.cpp b/cpp/scenario/gazebo/src/utils.cpp index ba85771fa..10407b331 100644 --- a/cpp/scenario/gazebo/src/utils.cpp +++ b/cpp/scenario/gazebo/src/utils.cpp @@ -242,3 +242,25 @@ std::string utils::getInstallPrefix() return ""; #endif } + +std::string utils::URDFFileToSDFString(const std::string& urdfFile) +{ + auto root = getSdfRootFromFile(urdfFile); + + if (!root) { + return ""; + } + + return root->Element()->ToString(""); +} + +std::string utils::URDFStringToSDFString(const std::string& urdfString) +{ + auto root = getSdfRootFromString(urdfString); + + if (!root) { + return ""; + } + + return root->Element()->ToString(""); +} From 3b7405d5cf68e4c8de222073f7b519e8738366b2 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 12:52:43 +0200 Subject: [PATCH 10/15] Add test for SDFRandomizer --- .../test_gym_ignition/test_sdf_randomizer.py | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 tests/test_gym_ignition/test_sdf_randomizer.py diff --git a/tests/test_gym_ignition/test_sdf_randomizer.py b/tests/test_gym_ignition/test_sdf_randomizer.py new file mode 100644 index 000000000..b66f4aef9 --- /dev/null +++ b/tests/test_gym_ignition/test_sdf_randomizer.py @@ -0,0 +1,308 @@ +# Copyright (C) 2020 Istituto Italiano di Tecnologia (IIT). All rights reserved. +# This software may be modified and distributed under the terms of the +# GNU Lesser General Public License v2.1 or any later version. + +import pytest +pytestmark = pytest.mark.gym_ignition + +from lxml import etree +import gym_ignition_models +from gym_ignition.utils import misc +from gym_ignition.randomizers.model import sdf +from gym_ignition import scenario_bindings as bindings +from gym_ignition.randomizers.model.sdf import Distribution, Method +from gym_ignition.randomizers.model.sdf import UniformParams, GaussianParams + + +def test_sdf_randomizer(): + + # Get the URDF model + urdf_model = gym_ignition_models.get_model_file("cartpole") + + # Convert it to a SDF string + sdf_model_string = bindings.URDFFileToSDFString(urdf_model) + + # Write the SDF string to a temp file + sdf_model = misc.string_to_file(sdf_model_string) + + # Create the randomizer + randomizer = sdf.SDFRandomizer(sdf_model=sdf_model) + + # Get the original model string. It is parsed and then serialized without changes. + orig_model = randomizer.sample(pretty_print=True) + + with pytest.raises(ValueError): + # Setting wrong distribution + randomizer.new_randomization() \ + .at_xpath("*/link[@name='pole']/inertial/inertia/ixx") \ + .method(Method.Additive) \ + .sampled_from(Distribution.Uniform, GaussianParams(mean=0, variance=0.1)) \ + .add() + + # Add a uniform randomization + randomizer.new_randomization() \ + .at_xpath("*/link[@name='pole']/inertial/inertia/ixx") \ + .method(Method.Additive) \ + .sampled_from(Distribution.Gaussian, GaussianParams(mean=0, variance=0.1)) \ + .add() + + randomizer.process_data() + assert len(randomizer.get_active_randomizations()) == 1 + + assert randomizer.sample(pretty_print=True) != orig_model + + # Clean the randomizer + randomizer.clean() + assert len(randomizer.get_active_randomizations()) == 0 + assert randomizer.sample(pretty_print=True) == orig_model + + # Add a multi-match randomization + randomizer.new_randomization() \ + .at_xpath("*/link/inertial/inertia/ixx") \ + .method(Method.Coefficient) \ + .sampled_from(Distribution.Uniform, UniformParams(low=0.8, high=1.2)) \ + .add() + + assert len(randomizer.get_active_randomizations()) == 1 + + # Expand the matches + randomizer.process_data() + assert len(randomizer.get_active_randomizations()) > 1 + + # Sample + assert randomizer.sample(pretty_print=True) != orig_model + + +def test_randomizer_reproducibility(): + + # Get the model + sdf_model = gym_ignition_models.get_model_file("ground_plane") + + # Initialize the randomizers + randomizer1 = sdf.SDFRandomizer(sdf_model=sdf_model) + randomizer2 = sdf.SDFRandomizer(sdf_model=sdf_model) + randomizer3 = sdf.SDFRandomizer(sdf_model=sdf_model) + + # Randomize the ground friction of all links (the ground plane collision) + frictions = randomizer1.find_xpath("*/link/collision/surface/friction") + assert len(frictions) == 1 + + # Get the original model string. It is parsed and then serialized without changes. + orig_model_string1 = randomizer1.sample(pretty_print=True) + orig_model_string2 = randomizer2.sample(pretty_print=True) + orig_model_string3 = randomizer3.sample(pretty_print=True) + assert orig_model_string1 == orig_model_string2 == orig_model_string3 + + # Do not seed #3 + randomizer1.seed(42) + randomizer2.seed(42) + + # Add randomizations for #1 + randomizer1.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=100)) \ + .add() + randomizer1.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu2") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=50)) \ + .add() + + # Add randomizations for #2 + randomizer2.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=100)) \ + .add() + randomizer2.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu2") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=50)) \ + .add() + + # Add randomizations for #3 + randomizer3.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=100)) \ + .add() + randomizer3.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu2") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=50)) \ + .add() + + # Process the randomizations + randomizer1.process_data() + randomizer2.process_data() + randomizer3.process_data() + + for _ in range(5): + model1 = randomizer1.sample() + model2 = randomizer2.sample() + model3 = randomizer3.sample() + + assert model1 == model2 + assert model1 != model3 + + +def test_randomize_missing_element(): + + # Get the URDF model + urdf_model = gym_ignition_models.get_model_file("pendulum") + + # Convert it to a SDF string + sdf_model_string = bindings.URDFFileToSDFString(urdf_model) + + # Write the SDF string to a temp file + sdf_model = misc.string_to_file(sdf_model_string) + + # Create the randomizer + randomizer = sdf.SDFRandomizer(sdf_model=sdf_model) + + # Try to randomize a missing element + with pytest.raises(RuntimeError): + # The ode/mu elements are missing + randomizer.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=100)) \ + .add() + + # Add the missing friction/ode/mu element. We assume that friction exists. + frictions = randomizer.find_xpath("*/link/collision/surface/friction") + + for friction in frictions: + + # Create parent 'ode' first + if friction.find("ode") is None: + etree.SubElement(friction, "ode") + + # Create child 'mu' after + ode = friction.find("ode") + if ode.find("mu") is None: + etree.SubElement(ode, "mu") + + # Assign a dummy value to mu + mu = ode.find("mu") + mu.text = str(0) + + # Apply the same randomization + randomizer.new_randomization() \ + .at_xpath("*/link/collision/surface/friction/ode/mu") \ + .method(Method.Absolute) \ + .sampled_from(Distribution.Uniform, + UniformParams(low=0, high=100)) \ + .ignore_zeros(False) \ + .add() + + # Process the randomization and sample a model + randomizer.process_data() + + model1 = randomizer.sample(pretty_print=True) + model2 = randomizer.sample(pretty_print=True) + assert model1 != model2 + + +def test_full_panda_randomization(): + + # Get the URDF model + urdf_model = gym_ignition_models.get_model_file("panda") + + # Convert it to a SDF string + sdf_model_string = bindings.URDFFileToSDFString(urdf_model) + + # Write the SDF string to a temp file + sdf_model = misc.string_to_file(sdf_model_string) + + # Create the randomizer + randomizer = sdf.SDFRandomizer(sdf_model=sdf_model) + + joint_dynamics = randomizer.find_xpath("*/joint/axis/dynamics") + assert len(joint_dynamics) > 0 + + # Add the friction and damping elements since they're missing in the model + for joint_dynamic in joint_dynamics: + + if joint_dynamic.find("friction") is None: + etree.SubElement(joint_dynamic, "friction") + friction = joint_dynamic.find("friction") + friction.text = str(0) + + if joint_dynamic.find("damping") is None: + etree.SubElement(joint_dynamic, "damping") + damping = joint_dynamic.find("damping") + damping.text = str(3) + + randomization_config = { + "*/link/inertial/mass": { + # mass + U(-0.5, 0.5) + 'method': Method.Additive, + 'distribution': Distribution.Uniform, + 'params': UniformParams(low=-0.5, high=0.5), + 'ignore_zeros': True, + 'force_positive': True, + }, + "*/link/inertial/inertia/ixx": { + # inertia * N(1, 0.2) + 'method': Method.Coefficient, + 'distribution': Distribution.Gaussian, + 'params': GaussianParams(mean=1.0, variance=0.2), + 'ignore_zeros': True, + 'force_positive': True, + }, + "*/link/inertial/inertia/iyy": { + 'method': Method.Coefficient, + 'distribution': Distribution.Gaussian, + 'params': GaussianParams(mean=1.0, variance=0.2), + 'ignore_zeros': True, + 'force_positive': True, + }, + "*/link/inertial/inertia/izz": { + 'method': Method.Coefficient, + 'distribution': Distribution.Gaussian, + 'params': GaussianParams(mean=1.0, variance=0.2), + 'ignore_zeros': True, + 'force_positive': True, + }, + "*/joint/axis/dynamics/friction": { + # friction in [0, 5] + 'method': Method.Absolute, + 'distribution': Distribution.Uniform, + 'params': UniformParams(low=0, high=5), + 'ignore_zeros': False, # We initialized the value as 0 + 'force_positive': True, + }, + "*/joint/axis/dynamics/damping": { + # damping (= 3.0) * [0.8, 1.2] + 'method': Method.Coefficient, + 'distribution': Distribution.Uniform, + 'params': UniformParams(low=0.8, high=1.2), + 'ignore_zeros': True, + 'force_positive': True, + }, + # TODO: */joint/axis/limit/effort + } + + for xpath, config in randomization_config.items(): + + randomizer.new_randomization() \ + .at_xpath(xpath) \ + .method(config["method"]) \ + .sampled_from(config["distribution"], config['params']) \ + .force_positive(config["distribution"]) \ + .ignore_zeros(config["ignore_zeros"]) \ + .add() + + randomizer.process_data() + assert len(randomizer.get_active_randomizations()) > 0 + + randomizer.sample(pretty_print=True) From ed89799adf998cae51ae37ba068a5df17a92e1c0 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 15:30:14 +0200 Subject: [PATCH 11/15] Update example --- examples/python/launch_cartpole.py | 53 ++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/examples/python/launch_cartpole.py b/examples/python/launch_cartpole.py index 3add9814b..8ff850a75 100755 --- a/examples/python/launch_cartpole.py +++ b/examples/python/launch_cartpole.py @@ -4,30 +4,46 @@ import gym import time +import functools +from gym_ignition.utils import logger +from gym_ignition_environments import randomizers -# Set gym verbosity -gym.logger.set_level(gym.logger.INFO) -assert gym.logger.set_level(gym.logger.DEBUG) or True +# Set verbosity +logger.set_level(gym.logger.ERROR) +# logger.set_level(gym.logger.DEBUG) + +# Available tasks +env_id = "CartPoleDiscreteBalancing-Gazebo-v0" +# env_id = "CartPoleContinuousBalancing-Gazebo-v0" +# env_id = "CartPoleContinuousSwingup-Gazebo-v0" + + +def make_env_from_id(env_id: str, **kwargs) -> gym.Env: + import gym + import gym_ignition_environments + return gym.make(env_id, **kwargs) -# Register gym-ignition environments -import gym_ignition -from gym_ignition.utils import logger -# Create the environment -# env = gym.make("CartPole-v1") -# env = gym.make("CartPoleDiscrete-Gympp-v0") -env = gym.make("CartPoleDiscrete-Gazebo-v0") -# env = gym.make("CartPoleContinuous-Gazebo-v0") -# env = gym.make("CartPoleDiscrete-PyBullet-v0") +# Create a partial function passing the environment id +make_env = functools.partial(make_env_from_id, env_id=env_id) + +# Wrap the environment with the randomizer. +# This is a simple example no randomization are applied. +env = randomizers.cartpole_no_rand.CartpoleEnvNoRandomizations(env=make_env) + +# Wrap the environment with the randomizer. +# This is a complex example that randomizes both the physics and the model. +# env = randomizers.cartpole.CartpoleEnvRandomizer( +# env=make_env, seed=42, num_physics_rollouts=2) # Enable the rendering -env.render('human') -time.sleep(3) +# env.render('human') # Initialize the seed env.seed(42) -for epoch in range(30): +for epoch in range(10): + # Reset the environment observation = env.reset() @@ -36,12 +52,13 @@ totalReward = 0 while not done: + # Execute a random action action = env.action_space.sample() observation, reward, done, _ = env.step(action) - # Render the environment - # It is not required to call this in the loop + # Render the environment. + # It is not required to call this in the loop if physics is not randomized. # env.render('human') # Accumulate the reward @@ -53,7 +70,7 @@ msg += "\t%.6f" % value logger.debug(msg) - logger.info(f"Total reward for episode #{epoch}: {totalReward}") + print(f"Reward episode #{epoch}: {totalReward}") env.close() time.sleep(5) From 17813560642506f235d20b5f611060ceff13eed6 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Mon, 20 Apr 2020 15:58:38 +0200 Subject: [PATCH 12/15] Add test to ensure randomizers reproducibility --- .../test_gym_ignition/test_reproducibility.py | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/tests/test_gym_ignition/test_reproducibility.py b/tests/test_gym_ignition/test_reproducibility.py index 0b1afacd9..f83c422bd 100644 --- a/tests/test_gym_ignition/test_reproducibility.py +++ b/tests/test_gym_ignition/test_reproducibility.py @@ -5,8 +5,53 @@ import pytest pytestmark = pytest.mark.gym_ignition +import gym +from gym_ignition_environments import randomizers + + +def make_env(**kwargs) -> gym.Env: + import gym + import gym_ignition_environments + return gym.make("CartPoleDiscreteBalancing-Gazebo-v0", **kwargs) + -@pytest.mark.xfail def test_reproducibility(): - assert False + env1 = randomizers.cartpole.CartpoleEnvRandomizer(env=make_env, seed=42) + env2 = randomizers.cartpole.CartpoleEnvRandomizer(env=make_env, seed=42) + assert env1 != env2 + + # Seed the environment + env1.seed(42) + env2.seed(42) + + for _ in range(5): + + # Reset the environments + observation1 = env1.reset() + observation2 = env2.reset() + assert observation1 == pytest.approx(observation2) + + # Initialize returned values + done = False + + while not done: + + # Sample a random action + action1 = env1.action_space.sample() + action2 = env2.action_space.sample() + assert action1 == pytest.approx(action2) + + # Step the environment + observation1, reward1, done1, info1 = env1.step(action1) + observation2, reward2, done2, info2 = env2.step(action2) + + assert done1 == pytest.approx(done2) + assert info1 == pytest.approx(info2) + assert reward1 == pytest.approx(reward2) + assert observation1 == pytest.approx(observation2) + + done = done1 + + env1.close() + env2.close() \ No newline at end of file From 3a8e36067090758b1cac6ab9b03218989ad5dde0 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Sun, 26 Apr 2020 12:33:11 +0200 Subject: [PATCH 13/15] Change method name to improve clarity --- python/gym_ignition/randomizers/base/physics.py | 2 +- python/gym_ignition/randomizers/gazebo_env_randomizer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/gym_ignition/randomizers/base/physics.py b/python/gym_ignition/randomizers/base/physics.py index 177bb2e07..aa781b64c 100644 --- a/python/gym_ignition/randomizers/base/physics.py +++ b/python/gym_ignition/randomizers/base/physics.py @@ -45,7 +45,7 @@ def seed_physics_randomizer(self, seed: int) -> None: """ pass - def add_rollout_to_physics(self) -> None: + def increase_rollout_counter(self) -> None: """ Increase the rollouts counter. """ diff --git a/python/gym_ignition/randomizers/gazebo_env_randomizer.py b/python/gym_ignition/randomizers/gazebo_env_randomizer.py index a81eb2d07..cf4f176b3 100644 --- a/python/gym_ignition/randomizers/gazebo_env_randomizer.py +++ b/python/gym_ignition/randomizers/gazebo_env_randomizer.py @@ -91,7 +91,7 @@ def reset(self, **kwargs) -> typing.Observation: self.env.task.np_random = np_random # Mark the beginning of a new rollout - self.env.physics_randomizer.add_rollout_to_physics() + self.env.physics_randomizer.increase_rollout_counter() # Reset the task through the TaskRandomizer self.randomize_task(self.env.task, self.env.gazebo, **kwargs) From af2c66438c5a3dff546d9cea12129adcc0cccf0c Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Sun, 26 Apr 2020 12:33:58 +0200 Subject: [PATCH 14/15] Fix typo --- python/gym_ignition/randomizers/base/task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/gym_ignition/randomizers/base/task.py b/python/gym_ignition/randomizers/base/task.py index c12b27b81..61eb9ba5d 100644 --- a/python/gym_ignition/randomizers/base/task.py +++ b/python/gym_ignition/randomizers/base/task.py @@ -22,7 +22,7 @@ def randomize_task(self, gazebo: a :py:class:`~scenario_bindings.GazeboSimulator` instance. Note: - Note the each task has a :py:attr:`~gym_ignition.base.task.Task.world` + Note that each task has a :py:attr:`~gym_ignition.base.task.Task.world` property that provides access to the simulated :py:class:`scenario_bindings.World`. """ From ed0c1cbd23c5293dfd0fd9b8bdfe79ed4863a563 Mon Sep 17 00:00:00 2001 From: Diego Ferigo Date: Sun, 26 Apr 2020 13:11:47 +0200 Subject: [PATCH 15/15] Document the SDFRandomizer class --- python/gym_ignition/randomizers/model/sdf.py | 140 +++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/python/gym_ignition/randomizers/model/sdf.py b/python/gym_ignition/randomizers/model/sdf.py index 0bc544478..b32418a49 100644 --- a/python/gym_ignition/randomizers/model/sdf.py +++ b/python/gym_ignition/randomizers/model/sdf.py @@ -44,6 +44,14 @@ class UniformParams(NamedTuple): class RandomizationDataBuilder: + """ + Builder class of a :py:class:`~gym_ignition.randomizers.model.sdf.RandomizationData` + object. + + Args: + randomizer: The :py:class:`~gym_ignition.randomizers.model.sdf.SDFRandomizer` + object to which the created randomization will be inserted. + """ def __init__(self, randomizer: "SDFRandomizer"): @@ -51,12 +59,31 @@ def __init__(self, randomizer: "SDFRandomizer"): self.randomizer = randomizer def at_xpath(self, xpath: str) -> "RandomizationDataBuilder": + """ + Set the XPath pattern associated to the randomization. + + Args: + xpath: The XPath pattern. + + Returns: + The randomization builder to allow chaining methods. + """ self.storage["xpath"] = xpath return self def sampled_from(self, distribution: Distribution, parameters: DistributionParameters) -> "RandomizationDataBuilder": + """ + Set the distribution associated to the randomization. + + Args: + distribution: The desired distribution. + parameters: The namedtuple with the parameters of the distribution. + + Returns: + The randomization builder to allow chaining methods. + """ self.storage["distribution"] = distribution self.storage["parameters"] = parameters @@ -72,19 +99,62 @@ def sampled_from(self, return self def method(self, method: Method) -> "RandomizationDataBuilder": + """ + Set the randomization method. + + Args: + method: The desired randomization method. + + Returns: + The randomization builder to allow chaining methods. + """ self.storage["method"] = method return self def ignore_zeros(self, ignore_zeros: bool) -> "RandomizationDataBuilder": + """ + Ignore the randomization of values that are zero. + + If the value to randomize has a default value of 0 in the SDF, when this method + is chained the randomization is skipped. In the case of a multi-match XPath + pattern, the values that are not zero are not skipped. + + Args: + ignore_zeros: True if zeros should be ignored, false otherwise. + + Returns: + The randomization builder to allow chaining methods. + """ + + self.storage["ignore_zeros"] = ignore_zeros return self def force_positive(self, force_positive: bool = True) -> "RandomizationDataBuilder": + """ + Force the randomized value to be greater than zero. + + This option is helpful to enforce that values e.g. the mass will stay positive + regardless of the applied distribution parameters. + + Args: + force_positive: True to force positive parameters, false otherwise. + + Returns: + The randomization builder to allow chaining methods. + """ + self.storage["force_positive"] = force_positive return self def add(self) -> None: + """ + Close the chaining of methods are return to the SDF randomizer the configuration. + + Raises: + RuntimeError: If the XPath pattern does not find any match in the SDF. + """ data = RandomizationData(**self.storage) @@ -95,6 +165,15 @@ def add(self) -> None: class SDFRandomizer: + """ + Randomized SDF files generator. + + Args: + sdf_model: The absolute path to the SDF file. + + Raises: + ValueError: If the SDF file does not exist. + """ def __init__(self, sdf_model: str): @@ -117,12 +196,36 @@ def __init__(self, sdf_model: str): self.rng = np.random.default_rng() def seed(self, seed: int) -> None: + """ + Seed the SDF randomizer. + + Args: + seed: The seed number. + """ self.rng = np.random.default_rng(seed) def find_xpath(self, xpath: str) -> List[etree.Element]: + """ + Find the elements that match an XPath pattern. + + This method could be helpful to test the matches of a XPath pattern before using + it in :py:meth:`~gym_ignition.randomizers.model.sdf.RandomizationDataBuilder.at_xpath`. + + Args: + xpath: The XPath pattern. + + Return: + A list of elements matching the XPath pattern. + """ return self._root.findall(xpath) def process_data(self) -> None: + """ + Process all the inserted randomizations. + + Raises: + RuntimeError: If the XPath of a randomization has no matches. + """ # Since we support multi-match XPaths, we expand all the individual matches expanded_randomizations = [] @@ -160,6 +263,19 @@ def process_data(self) -> None: self._randomizations = expanded_randomizations def sample(self, pretty_print=False) -> str: + """ + Sample a randomized SDF string. + + Args: + pretty_print: True to pretty print the output. + + Raises: + ValueError: If the distribution of a randomization is not recognized. + ValueError: If the method of a randomization is not recognized. + + Returns: + The randomized model as SDF string. + """ for data in self._randomizations: @@ -200,15 +316,39 @@ def sample(self, pretty_print=False) -> str: return etree.tostring(self._root, pretty_print=pretty_print).decode() def new_randomization(self) -> RandomizationDataBuilder: + """ + Start the chaining to build a new randomization. + + Return: + A randomization builder. + """ return RandomizationDataBuilder(randomizer=self) def insert(self, randomization_data) -> None: + """ + Insert a randomization. + + Args: + randomization_data: A new randomization. + """ self._randomizations.append(randomization_data) def get_active_randomizations(self) -> List[RandomizationData]: + """ + Return the active randomizations. + + This method could be helpful also in the case of multi-match XPath patterns to + validate that the inserted randomizations have been processed correctly. + + Returns: + The list of the active randomizations. + """ return self._randomizations def clean(self) -> None: + """ + Clean the SDF randomizer. + """ self._randomizations = [] self._default_values = {}