diff --git a/.github/workflows/ci-base-tests-linux.yml b/.github/workflows/ci-base-tests-linux.yml index e9d75ff16b..36dc319969 100644 --- a/.github/workflows/ci-base-tests-linux.yml +++ b/.github/workflows/ci-base-tests-linux.yml @@ -39,7 +39,7 @@ jobs: . ${{env.venv_dir}}/bin/activate pip install --upgrade pip pip install --upgrade wheel - pip install -e .[camera-obs,opendrive,rllib,test,torch,train] + pip install -e .[camera-obs,opendrive,rllib,test,test-notebook,torch,train] - name: Run smoke tests run: | . ${{env.venv_dir}}/bin/activate diff --git a/.github/workflows/ci-base-tests-mac.yml b/.github/workflows/ci-base-tests-mac.yml index c417be9fb5..7816eb0f1f 100644 --- a/.github/workflows/ci-base-tests-mac.yml +++ b/.github/workflows/ci-base-tests-mac.yml @@ -47,7 +47,7 @@ jobs: pip install --upgrade pip pip install --upgrade wheel pip install -r utils/setup/mac_requirements.txt - pip install -e .[camera-obs,opendrive,rllib,test,torch,train] + pip install -e .[camera-obs,opendrive,rllib,test,test-notebook,torch,train] - name: Run smoke tests run: | . ${{env.venv_dir}}/bin/activate diff --git a/cli/studio.py b/cli/studio.py index 1128fb051e..69f82c5a59 100644 --- a/cli/studio.py +++ b/cli/studio.py @@ -52,88 +52,24 @@ def scenario_cli(): ) @click.argument("scenario", type=click.Path(exists=True), metavar="") def build_scenario(clean: bool, allow_offset_map: bool, scenario: str): - _build_single_scenario(clean, allow_offset_map, scenario) - - -def _build_single_scenario(clean: bool, allow_offset_map: bool, scenario: str): click.echo(f"build-scenario {scenario}") - if clean: - _clean(scenario) - - scenario_root = Path(scenario) - scenario_root_str = str(scenario_root) + from smarts.sstudio.build_scenario import build_single_scenario - scenario_py = scenario_root / "scenario.py" - if scenario_py.exists(): - _install_requirements(scenario_root) - subprocess.check_call([sys.executable, "scenario.py"], cwd=scenario_root) - - from smarts.core.scenario import Scenario - - traffic_histories = Scenario.discover_traffic_histories(scenario_root_str) - # don't shift maps for scenarios with traffic histories since history data must line up with map - shift_to_origin = not allow_offset_map and not bool(traffic_histories) - - map_spec = Scenario.discover_map(scenario_root_str, shift_to_origin=shift_to_origin) - road_map, _ = map_spec.builder_fn(map_spec) - if not road_map: - click.echo( - "No reference to a RoadNetwork file was found in {}, or one could not be created. " - "Please make sure the path passed is a valid Scenario with RoadNetwork file required " - "(or a way to create one) for scenario building.".format(scenario_root_str) - ) - return - - road_map.to_glb(os.path.join(scenario_root, "map.glb")) + build_single_scenario(clean, allow_offset_map, scenario, click.echo) def _build_single_scenario_proc( clean: bool, allow_offset_map: bool, scenario: str, semaphore: synchronize.Semaphore ): + from smarts.sstudio.build_scenario import build_single_scenario + semaphore.acquire() try: - _build_single_scenario(clean, allow_offset_map, scenario) + build_single_scenario(clean, allow_offset_map, scenario, click.echo) finally: semaphore.release() -def _install_requirements(scenario_root): - import importlib.resources as pkg_resources - - requirements_txt = scenario_root / "requirements.txt" - if requirements_txt.exists(): - import zoo.policies - - with pkg_resources.path(zoo.policies, "") as path: - # Serve policies through the static file server, then kill after - # we've installed scenario requirements - pip_index_proc = subprocess.Popen( - ["twistd", "-n", "web", "--path", path], - # Hide output to keep display simple - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - ) - - pip_install_cmd = [ - sys.executable, - "-m", - "pip", - "install", - "-r", - str(requirements_txt), - ] - - click.echo( - f"Installing scenario dependencies via '{' '.join(pip_install_cmd)}'" - ) - - try: - subprocess.check_call(pip_install_cmd, stdout=subprocess.DEVNULL) - finally: - pip_index_proc.terminate() - pip_index_proc.wait() - - def _is_scenario_folder_to_build(path: str) -> bool: if os.path.exists(os.path.join(path, "waymo.yaml")): # for now, don't try to build Waymo scenarios... @@ -195,32 +131,9 @@ def build_all_scenarios(clean: bool, allow_offset_maps: bool, scenarios: str): ) @click.argument("scenario", type=click.Path(exists=True), metavar="") def clean_scenario(scenario: str): - _clean(scenario) + from smarts.sstudio.build_scenario import clean_scenario - -def _clean(scenario: str): - to_be_removed = [ - "map.glb", - "map_spec.pkl", - "bubbles.pkl", - "missions.pkl", - "flamegraph-perf.log", - "flamegraph.svg", - "flamegraph.html", - "*.rou.xml", - "*.rou.alt.xml", - "social_agents/*", - "traffic/*.rou.xml", - "traffic/*.smarts.xml", - "history_mission.pkl", - "*.shf", - "*-AUTOGEN.net.xml", - ] - p = Path(scenario) - for file_name in to_be_removed: - for f in p.glob(file_name): - # Remove file - f.unlink() + clean_scenario(scenario) @scenario_cli.command(name="replay", help="Play saved Envision data files in Envision.") diff --git a/docs/setup.rst b/docs/setup.rst index 523b576014..023414f2c2 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -32,6 +32,9 @@ To setup the simulator, which is called SMARTS, run the following commands, # OPTIONAL: install [camera-obs] version of python package with the panda3D dependencies if you want to render camera sensor observations in your simulations pip install -e .[camera-obs] + # OPTIONAL: install [opendrive] version of python package with the OpenDRIVE related dependencies if you are using the any OpenDRIVE related scenarios + pip install -e .[opendrive] + # make sure you can run sanity-test (and verify they are passing) # if tests fail, check './sanity_test_result.xml' for test report. pip install -e .[test] diff --git a/envision/client.py b/envision/client.py index 8192b16b91..5047f3e891 100644 --- a/envision/client.py +++ b/envision/client.py @@ -175,6 +175,7 @@ def read_and_send( wait_between_retries: float = 0.5, ): """Send a pre-recorded envision simulation to the envision server.""" + client = Client( endpoint=endpoint, wait_between_retries=wait_between_retries, diff --git a/envision/server.py b/envision/server.py index 996e1a104f..fd11c87e6e 100644 --- a/envision/server.py +++ b/envision/server.py @@ -483,6 +483,7 @@ def get(self): def make_app(scenario_dirs: Sequence, max_capacity_mb: float, debug: bool): """Create the envision web server application through composition of services.""" + with pkg_resources.path(web_dist, ".") as dist_path: return tornado.web.Application( [ diff --git a/examples/__init__.py b/examples/__init__.py index 4f7dcdde9b..e69de29bb2 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -1,10 +0,0 @@ -class RayException(Exception): - """An exception raised if ray package is required but not available.""" - - @classmethod - def required_to(cls, thing): - return cls( - f"""Ray Package is required to simulate {thing}. - You may not have installed the [rllib] or [train] dependencies required to run the ray dependent example. - Install them first using the command `pip install -e .[train, rllib]` at the source directory to install the package ray[rllib]==1.0.1.post1""" - ) diff --git a/examples/driving_in_traffic/requirements.txt b/examples/driving_in_traffic/requirements.txt index bbb8810f09..3a541c1ab4 100644 --- a/examples/driving_in_traffic/requirements.txt +++ b/examples/driving_in_traffic/requirements.txt @@ -113,5 +113,5 @@ websocket-client==1.2.1 Werkzeug==2.0.2 wrapt==1.12.1 yattag==1.14.0 -zipp==3.6.0 -zope.interface==5.4.0 \ No newline at end of file +zipp==3.7.0 +zope.interface==5.4.0 diff --git a/examples/env/create_run_visualize.ipynb b/examples/env/create_run_visualize.ipynb new file mode 100644 index 0000000000..f78c8497ef --- /dev/null +++ b/examples/env/create_run_visualize.ipynb @@ -0,0 +1,152 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "wkR0YvENQni4" + }, + "source": [ + "**Setup dependencies**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "n1sQVa5mkMEA", + "outputId": "dbfa09f5-0fd9-4a51-f728-fc62230f1b84" + }, + "outputs": [], + "source": [ + "!git clone https://github.com/huawei-noah/SMARTS 2> /dev/null\n", + "!cd SMARTS && ls && git checkout develop && pip install .[camera-obs,remote_agent]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "ubc6jEAqiCEq", + "outputId": "16e9facc-86bc-4f72-a4f2-2d8bb2961435" + }, + "outputs": [], + "source": [ + "import sys\n", + "sys.path.insert(0, \"/content/SMARTS/\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "s7UtcphinvNv" + }, + "source": [ + "**Import Base Modules**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "HsnDWYx_ngdc" + }, + "outputs": [], + "source": [ + "import gym\n", + "\n", + "from smarts.zoo import registry\n", + "from smarts.env.wrappers.episode_logger import EpisodeLogger" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "LFoG7Z-FobPP" + }, + "source": [ + "**Run an episode**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 501 + }, + "id": "BBWj3wbAso3J", + "outputId": "63c1d665-b4a5-43b5-d926-b02b5bcce08f" + }, + "outputs": [], + "source": [ + "from smarts.core.utils.episodes import episode_range\n", + "from smarts.env.wrappers.record_video import RecordVideo\n", + "\n", + "import examples.env.figure_eight_env\n", + "env = gym.make(\"figure_eight-v0\")\n", + "env: gym.Env = RecordVideo(\n", + " env, video_folder=\"videos\", video_length=40, step_trigger=lambda s: s % 100 == 0\n", + ")\n", + "env: gym.Env = EpisodeLogger(env)\n", + "\n", + "import zoo.policies.keep_lane_agent\n", + "agent = registry.make_agent(\"zoo.policies:keep-lane-agent-v0\")\n", + "\n", + "for episode in episode_range(max_steps=450):\n", + " observation = env.reset()\n", + " reward, done, info = None, False, None\n", + " while episode.continues(observation, reward, done, info):\n", + " action = agent.act(observation)\n", + " observation, reward, done, info = env.step(action)\n", + "\n", + "env.close()\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from smarts.env.wrappers.utils.rendering import show_notebook_videos\n", + "show_notebook_videos()" + ] + } + ], + "metadata": { + "colab": { + "name": "mock_demo.ipynb", + "provenance": [] + }, + "interpreter": { + "hash": "11a534571de20c647cd170207b7bb5d28d7f55463a5594e721a86394d5987d81" + }, + "kernelspec": { + "display_name": "Python 3.8.10 ('.venv': venv)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/examples/env/figure_eight_env.py b/examples/env/figure_eight_env.py new file mode 100644 index 0000000000..0ff5099c16 --- /dev/null +++ b/examples/env/figure_eight_env.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import gym + +from smarts.zoo.agent_spec import AgentSpec +from smarts.core.agent_interface import AgentInterface, AgentType +from smarts.env.wrappers.single_agent import SingleAgent + +agent_spec = AgentSpec( + interface=AgentInterface.from_type( + AgentType.Laner, + max_episode_steps=150, + rgb=True, + ogm=True, + drivable_area_grid_map=True, + ), + agent_builder=None, +) + + +def entry_point(*args, **kwargs): + from smarts.env.hiway_env import HiWayEnv + + scenario = str((Path(__file__).parent / "../../scenarios/figure_eight").resolve()) + ## Note: can build the scenario here + from smarts.sstudio.build_scenario import build_single_scenario + + build_single_scenario(clean=True, allow_offset_map=True, scenario=scenario) + hiwayenv = HiWayEnv( + agent_specs={"agent-007": agent_spec}, + scenarios=[scenario], + headless=True, + sumo_headless=True, + ) + hiwayenv.metadata["render.modes"] = set(hiwayenv.metadata["render.modes"]) | { + "rgb_array" + } + return SingleAgent(hiwayenv) + + +gym.register("figure_eight-v0", entry_point=entry_point) diff --git a/examples/multi_agent.ipynb b/examples/multi_agent.ipynb new file mode 100644 index 0000000000..797daae7f0 --- /dev/null +++ b/examples/multi_agent.ipynb @@ -0,0 +1,173 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Example: Multi Agent" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install SMARTS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Install SMARTS\n", + "!git clone https://github.com/huawei-noah/SMARTS /content/SMARTS\n", + "!cd SMARTS && ls && git checkout ipynb-test-deps && pip install .[camera-obs]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Build the scenario." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Build scenarios\n", + "!scl scenario build-all --clean /content/SMARTS/scenarios/figure_eight" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define the agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from smarts.core.agent import Agent\n", + "\n", + "class KeepLaneAgent(Agent):\n", + " def act(self, obs):\n", + " return \"keep_lane\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define the environment loop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import gym\n", + "\n", + "from smarts.core.agent import AgentSpec\n", + "from smarts.core.agent_interface import AgentInterface, AgentType\n", + "from smarts.core.utils.episodes import episodes\n", + "\n", + "N_AGENTS = 4\n", + "AGENT_IDS = [\"Agent %i\" % i for i in range(N_AGENTS)]\n", + "\n", + "def main(scenarios, num_episodes, max_episode_steps=None):\n", + " agent_specs = {\n", + " agent_id: AgentSpec(\n", + " interface=AgentInterface.from_type(\n", + " AgentType.Laner, max_episode_steps=max_episode_steps\n", + " ),\n", + " agent_builder=KeepLaneAgent,\n", + " )\n", + " for agent_id in AGENT_IDS\n", + " }\n", + "\n", + " env = gym.make(\n", + " \"smarts.env:hiway-v0\",\n", + " scenarios=scenarios,\n", + " agent_specs=agent_specs,\n", + " headless=True,\n", + " sumo_headless=True,\n", + " )\n", + "\n", + " for episode in episodes(n=num_episodes):\n", + " agents = {\n", + " agent_id: agent_spec.build_agent()\n", + " for agent_id, agent_spec in agent_specs.items()\n", + " }\n", + " observations = env.reset()\n", + " episode.record_scenario(env.scenario_log)\n", + "\n", + " dones = {\"__all__\": False}\n", + " while not dones[\"__all__\"]:\n", + " actions = {\n", + " agent_id: agents[agent_id].act(agent_obs)\n", + " for agent_id, agent_obs in observations.items()\n", + " }\n", + "\n", + " observations, rewards, dones, infos = env.step(actions)\n", + " episode.record_step(observations, rewards, dones, infos)\n", + "\n", + " env.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run the example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "main(\n", + " scenarios=[\"/content/SMARTS/scenarios/figure_eight\"],\n", + " num_episodes=3,\n", + " max_episode_steps=100,\n", + ")" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "fd69f43f58546b570e94fd7eba7b65e6bcc7a5bbc4eab0408017d18902915d69" + }, + "kernelspec": { + "display_name": "Python 3.7.5 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/ray_multi_instance.py b/examples/ray_multi_instance.py index ff26d88452..0a98fe5045 100644 --- a/examples/ray_multi_instance.py +++ b/examples/ray_multi_instance.py @@ -12,7 +12,7 @@ try: import ray except Exception as e: - from . import RayException + from smarts.core.utils.custom_exceptions import RayException raise RayException.required_to("ray_multi_instance.py") diff --git a/examples/rllib/rllib.py b/examples/rllib/rllib.py index 682ea82a21..ae01e54788 100644 --- a/examples/rllib/rllib.py +++ b/examples/rllib/rllib.py @@ -20,10 +20,7 @@ from ray.rllib.utils.typing import PolicyID from ray.tune.schedulers import PopulationBasedTraining except Exception as e: - if __name__ == "__main__": - from examples import RayException - else: - from .. import RayException + from smarts.core.utils.custom_exceptions import RayException raise RayException.required_to("rllib.py") diff --git a/examples/rllib/rllib_agent.py b/examples/rllib/rllib_agent.py index 465302ee1c..a50673f707 100644 --- a/examples/rllib/rllib_agent.py +++ b/examples/rllib/rllib_agent.py @@ -11,7 +11,7 @@ from ray.rllib.models.tf.fcnet import FullyConnectedNetwork from ray.rllib.utils import try_import_tf except Exception as e: - from .. import RayException + from smarts.core.utils.custom_exceptions import RayException raise RayException.required_to("rllib_agent.py") diff --git a/examples/single_agent.ipynb b/examples/single_agent.ipynb new file mode 100644 index 0000000000..c94f3c6d96 --- /dev/null +++ b/examples/single_agent.ipynb @@ -0,0 +1,179 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Example: Single Agent" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install SMARTS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Install SMARTS\n", + "!git clone https://github.com/huawei-noah/SMARTS /content/SMARTS\n", + "!cd SMARTS && ls && git checkout ipynb-test-deps && pip install .[camera-obs]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Build the scenario." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Build scenarios\n", + "!scl scenario build-all --clean /content/SMARTS/scenarios/figure_eight" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define the agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from smarts.core.agent import Agent\n", + "from smarts.core.sensors import Observation\n", + "\n", + "class ChaseViaPointsAgent(Agent):\n", + " def act(self, obs: Observation):\n", + " if (\n", + " len(obs.via_data.near_via_points) < 1\n", + " or obs.ego_vehicle_state.road_id != obs.via_data.near_via_points[0].road_id\n", + " ):\n", + " return (obs.waypoint_paths[0][0].speed_limit, 0)\n", + "\n", + " nearest = obs.via_data.near_via_points[0]\n", + " if nearest.lane_index == obs.ego_vehicle_state.lane_index:\n", + " return (nearest.required_speed, 0)\n", + "\n", + " return (\n", + " nearest.required_speed,\n", + " 1 if nearest.lane_index > obs.ego_vehicle_state.lane_index else -1,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define the environment loop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import gym\n", + "\n", + "from smarts.core.agent import AgentSpec\n", + "from smarts.core.agent_interface import AgentInterface, AgentType\n", + "from smarts.core.utils.episodes import episodes\n", + "from smarts.env.wrappers.single_agent import SingleAgent\n", + "\n", + "def main(scenarios, num_episodes, max_episode_steps=None):\n", + " agent_spec = AgentSpec(\n", + " interface=AgentInterface.from_type(\n", + " AgentType.LanerWithSpeed, max_episode_steps=max_episode_steps,\n", + " ),\n", + " agent_builder=ChaseViaPointsAgent,\n", + " )\n", + "\n", + " env = gym.make(\n", + " \"smarts.env:hiway-v0\",\n", + " scenarios=scenarios,\n", + " agent_specs={\"SingleAgent\": agent_spec},\n", + " headless=True,\n", + " sumo_headless=True,\n", + " )\n", + "\n", + " # Convert `env.step()` and `env.reset()` from multi-agent interface to\n", + " # single-agent interface.\n", + " env = SingleAgent(env=env)\n", + "\n", + " for episode in episodes(n=num_episodes):\n", + " agent = agent_spec.build_agent()\n", + " observation = env.reset()\n", + " episode.record_scenario(env.scenario_log)\n", + "\n", + " done = False\n", + " while not done:\n", + " agent_action = agent.act(observation)\n", + " observation, reward, done, info = env.step(agent_action)\n", + " episode.record_step(observation, reward, done, info)\n", + "\n", + " env.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run the example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "main(\n", + " scenarios=[\"/content/SMARTS/scenarios/figure_eight\"],\n", + " num_episodes=3,\n", + " max_episode_steps=100,\n", + ")" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "fd69f43f58546b570e94fd7eba7b65e6bcc7a5bbc4eab0408017d18902915d69" + }, + "kernelspec": { + "display_name": "Python 3.7.5 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.5" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/tools/regression_rllib.py b/examples/tools/regression_rllib.py index 83f114573f..557d57c7c4 100644 --- a/examples/tools/regression_rllib.py +++ b/examples/tools/regression_rllib.py @@ -14,7 +14,7 @@ from ray.rllib.models import ModelCatalog from ray.rllib.utils import try_import_tf except Exception as e: - from .. import RayException + from smarts.core.utils.custom_exceptions import RayException raise RayException.required_to("regression_rllib.py") @@ -45,6 +45,7 @@ def setup(self): ) def act(self, obs): + assert self._sess is not None, f"You must call {self.setup.__name__} first." obs = self._prep.transform(obs) graph = tf.compat.v1.get_default_graph() # These tensor names were found by inspecting the trained model diff --git a/examples/tools/stress_sumo.py b/examples/tools/stress_sumo.py index 4bc83e067d..04a7c83cc9 100644 --- a/examples/tools/stress_sumo.py +++ b/examples/tools/stress_sumo.py @@ -1,7 +1,7 @@ try: import ray except Exception as e: - from .. import RayException + from smarts.core.utils.custom_exceptions import RayException raise RayException.required_to("stress_sumo.py") diff --git a/setup.py b/setup.py index 161df2c541..774465c878 100644 --- a/setup.py +++ b/setup.py @@ -24,12 +24,12 @@ # 50.0 is broken: https://github.com/pypa/setupatools/issues/2353 "setuptools>=41.0.0,!=50.0", "cached-property>=1.5.2", - "click==8.0.4", # used in scl + "click>=7.1.2", # used in scl "eclipse-sumo==1.10.0", # sumo - "gym==0.19.0", + "gym>=0.17.3,<=0.19.0", "numpy>=1.19.5", # required for tf 2.4 below "pandas>=1.3.4", # only used by zoo/evaluation - "psutil>=5.8.0", + "psutil>=5.4.8", "pybullet==3.0.6", "rich>=11.2.0", "Rtree>=0.9.7", @@ -43,15 +43,14 @@ # The following is for both SS and Envision "cloudpickle>=1.3.0,<1.4.0", # The following are for /envision - "tornado>=6.1", + "tornado>=5.1.1", "websocket-client>=1.2.1", "ijson>=3.1.4", # The following are for the /smarts/algorithms - "matplotlib>=3.4.3", + "matplotlib>=3.2.2", # The following are for /smarts/zoo and remote agents - "grpcio==1.32.0", - "protobuf==3.20.1", - "PyYAML>=6.0", + "protobuf>=3.17.3", + "PyYAML>=3.13", "twisted>=21.7.0", ], extras_require={ @@ -70,6 +69,7 @@ "sphinxcontrib-apidoc>=0.3.0", ], "extras": ["pynput>=1.7.4"], # Used by HumanKeyboardAgent + "remote_agent": ["grpcio==1.32.0"], "rllib": [ "opencv-python==4.1.2.30", "opencv-python-headless==4.1.2.30", @@ -78,14 +78,16 @@ "ros": ["catkin_pkg", "rospkg"], "test": [ # The following are for testing - "ipykernel>=6.8.0", - "jupyter-client>=7.1.2", "pytest>=6.2.5", "pytest-benchmark>=3.4.1", "pytest-cov>=3.0.0", - "pytest-notebook>=0.7.0", "pytest-xdist>=2.4.0", ], + "test-notebook": [ + "ipykernel>=4.10.1", + "jupyter-client>=7.1.2", + "pytest-notebook>=0.7.0", + ], "torch": [ "torch==1.4.0", "torchvision==0.5.0", @@ -96,7 +98,9 @@ "waymo": [ "waymo-open-dataset-tf-2-4-0", ], - "opendrive": ["opendrive2lanelet>=1.2.1"], + "opendrive": [ + "opendrive2lanelet>=1.2.1", + ], }, entry_points={"console_scripts": ["scl=cli.cli:scl"]}, ) diff --git a/smarts/core/agent_buffer.py b/smarts/core/agent_buffer.py new file mode 100644 index 0000000000..e1b0e5bf89 --- /dev/null +++ b/smarts/core/agent_buffer.py @@ -0,0 +1,39 @@ +# Copyright (C) 2020. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import abc +from typing import Optional + +from smarts.core.buffer_agent import BufferAgent + + +class AgentBuffer(metaclass=abc.ABCMeta): + """Defines a buffer of agents for external use.""" + + @abc.abstractmethod + def destroy(self): + """Clean up the buffer resources.""" + raise NotImplementedError + + @abc.abstractmethod + def acquire_agent( + self, retries: int = 3, timeout: Optional[float] = None + ) -> BufferAgent: + """Get an agent from the buffer.""" + raise NotImplementedError diff --git a/smarts/core/agent_manager.py b/smarts/core/agent_manager.py index a1ad3756e6..bcdc7ca769 100644 --- a/smarts/core/agent_manager.py +++ b/smarts/core/agent_manager.py @@ -21,14 +21,13 @@ import logging from typing import Any, Dict, Optional, Set, Tuple, Union -import cloudpickle - from envision.types import format_actor_id from smarts.core.actor_role import ActorRole from smarts.core.agent_interface import AgentInterface from smarts.core.bubble_manager import BubbleManager from smarts.core.data_model import SocialAgent from smarts.core.plan import Plan +from smarts.core.heterogenous_agent_buffer import HeterogenousAgentBuffer from smarts.core.sensors import Observation, Sensors from smarts.core.utils.id import SocialAgentId from smarts.core.vehicle import VehicleState @@ -45,7 +44,7 @@ class AgentManager: def __init__(self, interfaces, zoo_addrs=None): self._log = logging.getLogger(self.__class__.__name__) - self._remote_agent_buffer = None + self._agent_buffer = None self._zoo_addrs = zoo_addrs self._ego_agent_ids = set() self._social_agent_ids = set() @@ -79,9 +78,9 @@ def teardown(self): def destroy(self): """Clean up remaining resources for deletion.""" - if self._remote_agent_buffer: - self._remote_agent_buffer.destroy() - self._remote_agent_buffer = None + if self._agent_buffer: + self._agent_buffer.destroy() + self._agent_buffer = None @property def agent_ids(self) -> Set[str]: @@ -283,9 +282,7 @@ def fetch_agent_actions( try: social_agent_actions = { agent_id: ( - cloudpickle.loads( - self._remote_social_agents_action[agent_id].result().action - ) + self._remote_social_agents_action[agent_id].result() if self._remote_social_agents_action.get(agent_id, None) else None ) @@ -385,22 +382,22 @@ def init_ego_agents(self, sim): for agent_id, agent_interface in self._initial_interfaces.items(): self.add_ego_agent(agent_id, agent_interface) + def _setup_agent_buffer(self): + if not self._agent_buffer: + self._agent_buffer = HeterogenousAgentBuffer( + zoo_manager_addrs=self._zoo_addrs + ) + def setup_social_agents(self, sim): """Initialize all social agents.""" social_agents = sim.scenario.social_agents if social_agents: - if not self._remote_agent_buffer: - from smarts.core.remote_agent_buffer import RemoteAgentBuffer - - self._remote_agent_buffer = RemoteAgentBuffer( - zoo_manager_addrs=self._zoo_addrs - ) + self._setup_agent_buffer() else: return self._remote_social_agents = { - agent_id: self._remote_agent_buffer.acquire_remote_agent() - for agent_id in social_agents + agent_id: self._agent_buffer.acquire_agent() for agent_id in social_agents } for agent_id, (social_agent, social_agent_model) in social_agents.items(): @@ -512,13 +509,8 @@ def _add_agent( def start_social_agent(self, agent_id, social_agent, agent_model): """Starts a managed social agent.""" - if not self._remote_agent_buffer: - from smarts.core.remote_agent_buffer import RemoteAgentBuffer - - self._remote_agent_buffer = RemoteAgentBuffer( - zoo_manager_addrs=self._zoo_addrs - ) - remote_agent = self._remote_agent_buffer.acquire_remote_agent() + self._setup_agent_buffer() + remote_agent = self._agent_buffer.acquire_agent() remote_agent.start(social_agent) self._remote_social_agents[agent_id] = remote_agent self._agent_interfaces[agent_id] = social_agent.interface diff --git a/smarts/core/buffer_agent.py b/smarts/core/buffer_agent.py new file mode 100644 index 0000000000..fe2e20f310 --- /dev/null +++ b/smarts/core/buffer_agent.py @@ -0,0 +1,44 @@ +# MIT License +# +# Copyright (C) 2021. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import abc +from abc import abstractmethod + +from smarts.zoo.agent_spec import AgentSpec + + +class BufferAgent(metaclass=abc.ABCMeta): + """An agent which is part of a buffer.""" + + @abstractmethod + def act(self, obs): + """Gives a future action based on observations.""" + raise NotImplementedError + + @abstractmethod + def start(self, agent_spec: AgentSpec): + """Begin operation of this agent.""" + raise NotImplementedError + + @abstractmethod + def terminate(self): + """Clean up agent resources.""" + raise NotImplementedError diff --git a/smarts/core/default_map_builder.py b/smarts/core/default_map_builder.py index 025d9a9a5e..76c3f4dea0 100644 --- a/smarts/core/default_map_builder.py +++ b/smarts/core/default_map_builder.py @@ -117,8 +117,12 @@ def get_road_map(map_spec) -> Tuple[Optional[RoadMap], Optional[str]]: map_class = SumoRoadNetwork elif map_type == _OPENDRIVE_MAP: - from smarts.core.opendrive_road_network import OpenDriveRoadNetwork + from smarts.core.utils.custom_exceptions import OpenDriveException + try: + from smarts.core.opendrive_road_network import OpenDriveRoadNetwork + except ImportError: + raise OpenDriveException.required_to("use OpenDRIVE maps") map_class = OpenDriveRoadNetwork elif map_type == _WAYMO_MAP: diff --git a/smarts/core/heterogenous_agent_buffer.py b/smarts/core/heterogenous_agent_buffer.py new file mode 100644 index 0000000000..1db9a4f47e --- /dev/null +++ b/smarts/core/heterogenous_agent_buffer.py @@ -0,0 +1,48 @@ +# Copyright (C) 2020. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from typing import Optional + +from smarts.core.agent_buffer import AgentBuffer +from smarts.core.buffer_agent import BufferAgent + + +class HeterogenousAgentBuffer(AgentBuffer): + """A buffer that manages social agents.""" + + def __init__(self, **kwargs): + try: + from smarts.core.remote_agent_buffer import RemoteAgentBuffer + + self._agent_buffer = RemoteAgentBuffer( + zoo_manager_addrs=kwargs.get("zoo_manager_addrs") + ) + + except (ImportError, ModuleNotFoundError): + from smarts.core.local_agent_buffer import LocalAgentBuffer + + self._agent_buffer = LocalAgentBuffer() + + def destroy(self): + self._agent_buffer.destroy() + + def acquire_agent( + self, retries: int = 3, timeout: Optional[float] = None + ) -> BufferAgent: + return self._agent_buffer.acquire_agent(retries=retries, timeout=timeout) diff --git a/smarts/core/local_agent.py b/smarts/core/local_agent.py new file mode 100644 index 0000000000..0ff191f08a --- /dev/null +++ b/smarts/core/local_agent.py @@ -0,0 +1,53 @@ +# Copyright (C) 2020. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from concurrent import futures + +from smarts.core.buffer_agent import BufferAgent +from smarts.zoo.agent_spec import AgentSpec + + +class LocalAgent(BufferAgent): + """A local implementation of a buffer agent.""" + + def __init__(self, act_executor: futures.Executor): + self._agent = None + self._agent_spec = None + self._act_executor = act_executor + + def act(self, obs): + """Call the agent's act function asynchronously and return a Future.""" + + def obtain_future(obs): + adapted_obs = self._agent_spec.observation_adapter(obs) + action = self._agent.act(adapted_obs) + adapted_action = self._agent_spec.action_adapter(action) + + return adapted_action + + act_future = self._act_executor.submit(obtain_future, obs) + return act_future + + def start(self, agent_spec: AgentSpec): + """Send the AgentSpec to the agent runner.""" + self._agent_spec = agent_spec + self._agent = self._agent_spec.build_agent() + + def terminate(self): + pass diff --git a/smarts/core/local_agent_buffer.py b/smarts/core/local_agent_buffer.py new file mode 100644 index 0000000000..e0e4e320a4 --- /dev/null +++ b/smarts/core/local_agent_buffer.py @@ -0,0 +1,44 @@ +# Copyright (C) 2020. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from concurrent.futures import ProcessPoolExecutor +from typing import Optional + +import psutil + +from smarts.core.agent_buffer import AgentBuffer +from smarts.core.buffer_agent import BufferAgent +from smarts.core.local_agent import LocalAgent + + +class LocalAgentBuffer(AgentBuffer): + """A buffer that manages social agents.""" + + def __init__(self): + num_cpus = max(2, psutil.cpu_count(logical=False) or (psutil.cpu_count() - 1)) + self._act_executor = ProcessPoolExecutor(num_cpus) + + def destroy(self): + self._act_executor.shutdown(wait=True) + + def acquire_agent( + self, retries: int = 3, timeout: Optional[float] = None + ) -> BufferAgent: + localAgent = LocalAgent(self._act_executor) + return localAgent diff --git a/smarts/core/remote_agent.py b/smarts/core/remote_agent.py index fa99877d5a..add18739f7 100644 --- a/smarts/core/remote_agent.py +++ b/smarts/core/remote_agent.py @@ -18,13 +18,13 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import logging -import time -from concurrent import futures +from functools import wraps from typing import Tuple import cloudpickle import grpc +from smarts.core.buffer_agent import BufferAgent from smarts.zoo import manager_pb2, manager_pb2_grpc, worker_pb2, worker_pb2_grpc from smarts.zoo.agent_spec import AgentSpec @@ -35,7 +35,7 @@ class RemoteAgentException(Exception): pass -class RemoteAgent: +class RemoteAgent(BufferAgent): """A remotely controlled agent.""" def __init__( @@ -58,7 +58,7 @@ def __init__( self._log = logging.getLogger(self.__class__.__name__) # Track the last action future. - self._act_future = None + self._grpc_future = None self._manager_channel = grpc.insecure_channel( f"{manager_address[0]}:{manager_address[1]}" @@ -80,11 +80,21 @@ def __init__( def act(self, obs): """Call the agent's act function asynchronously and return a Future.""" - self._act_future = self._worker_stub.act.future( + self._grpc_future = self._worker_stub.act.future( worker_pb2.Observation(payload=cloudpickle.dumps(obs)) ) - return self._act_future + def result_wrapper(f): + @wraps(f) + def wrapper(): + action = cloudpickle.loads(f().action) + return action + + return wrapper + + setattr(self._grpc_future, "result", result_wrapper(self._grpc_future.result)) + + return self._grpc_future def start(self, agent_spec: AgentSpec): """Send the AgentSpec to the agent runner.""" @@ -96,8 +106,8 @@ def start(self, agent_spec: AgentSpec): def terminate(self): """Close the agent connection and invalidate this agent.""" # If the last action future returned is incomplete, cancel it first. - if (self._act_future is not None) and (not self._act_future.done()): - self._act_future.cancel() + if (self._grpc_future is not None) and (not self._grpc_future.done()): + self._grpc_future.cancel() try: # Stop the remote worker process diff --git a/smarts/core/remote_agent_buffer.py b/smarts/core/remote_agent_buffer.py index 2262ec1398..1fa7d4a990 100644 --- a/smarts/core/remote_agent_buffer.py +++ b/smarts/core/remote_agent_buffer.py @@ -30,12 +30,14 @@ import grpc +from smarts.core.agent_buffer import AgentBuffer +from smarts.core.buffer_agent import BufferAgent from smarts.core.remote_agent import RemoteAgent, RemoteAgentException from smarts.core.utils.networking import find_free_port from smarts.zoo import manager_pb2, manager_pb2_grpc -class RemoteAgentBuffer: +class RemoteAgentBuffer(AgentBuffer): """A buffer that manages social agents.""" def __init__( @@ -190,9 +192,9 @@ def _try_to_acquire_remote_agent(self, timeout: float): remote_agent = future.result(timeout=timeout) return remote_agent - def acquire_remote_agent( + def acquire_agent( self, retries: int = 3, timeout: Optional[float] = None - ) -> RemoteAgent: + ) -> BufferAgent: """Creates RemoteAgent objects. Args: diff --git a/smarts/core/renderer.py b/smarts/core/renderer.py index 6f9701e0da..4458d132ba 100644 --- a/smarts/core/renderer.py +++ b/smarts/core/renderer.py @@ -85,6 +85,7 @@ def __new__(cls): loadPrcFileData("", "aux-display pandagles") loadPrcFileData("", "aux-display pandagles2") loadPrcFileData("", "aux-display p3tinydisplay") + # disable vsync otherwise we are limited to refresh-rate of screen loadPrcFileData("", "sync-video false") loadPrcFileData("", "model-path %s" % os.getcwd()) diff --git a/smarts/core/sumo_road_network.py b/smarts/core/sumo_road_network.py index c1cd4c0029..a3ca0af65e 100644 --- a/smarts/core/sumo_road_network.py +++ b/smarts/core/sumo_road_network.py @@ -210,7 +210,10 @@ def is_same_map(self, map_spec: MapSpec) -> bool: ) and ( map_spec.shift_to_origin == self._map_spec.shift_to_origin - or (not map_spec.shift_to_origin and not self._graph._shifted_by_smarts) + or ( + not map_spec.shift_to_origin + and not getattr(self._graph, "_shifted_by_smarts", False) + ) ) ) diff --git a/smarts/core/utils/custom_exceptions.py b/smarts/core/utils/custom_exceptions.py index 7804b23dcd..0730996d0b 100644 --- a/smarts/core/utils/custom_exceptions.py +++ b/smarts/core/utils/custom_exceptions.py @@ -28,3 +28,29 @@ def required_to(cls, thing: str) -> "RendererException": return cls( f"""A renderer is required to {thing}. You may not have installed the [camera-obs] dependencies required to render the camera sensor observations. Install them first using the command `pip install -e .[camera-obs]` at the source directory.""" ) + + +class RayException(Exception): + """An exception raised if ray package is required but not available.""" + + @classmethod + def required_to(cls, thing): + """Generate a `RayException` requiring a render to do `thing`.""" + return cls( + f"""Ray Package is required to {thing}. + You may not have installed the [rllib] or [train] dependencies required to run the ray dependent example. + Install them first using the command `pip install -e .[train, rllib]` at the source directory to install the package ray[rllib]==1.0.1.post1""" + ) + + +class OpenDriveException(Exception): + """An exception raised if opendrive utilities are required but not available.""" + + @classmethod + def required_to(cls, thing): + """Generate an instance of this exception that describes what can be done to remove the exception""" + return cls( + f"""OpenDRIVE Package is required to {thing}. + You may not have installed the [opendrive] dependencies required to run the OpenDRIVE dependent example. + Install them first using the command `pip install -e .[opendrive]` at the source directory to install the necessary packages""" + ) diff --git a/smarts/core/utils/episodes.py b/smarts/core/utils/episodes.py index 2947b5badc..1f1673ca29 100644 --- a/smarts/core/utils/episodes.py +++ b/smarts/core/utils/episodes.py @@ -21,10 +21,83 @@ import time from collections import defaultdict from dataclasses import dataclass, field +from typing import Optional, Union import tableprint as tp +class EpisodeLogs: + """An episode logging utility.""" + + def __init__(self, col_width, total_episodes: Union[str, int] = "?") -> None: + self._col_width = col_width + self._table = self.context(col_width) + self._current_episode: Optional[EpisodeLog] = None + self._total_episodes = total_episodes + self._current_episode_num = 0 + + def reset(self) -> "EpisodeLog": + """Record an episode reset.""" + + e = self._current_episode + if e: + self._write_row() + self._current_episode_num += 1 + self._current_episode = EpisodeLog(self._current_episode_num) + return self._current_episode + + def _write_row(self): + assert isinstance(self._current_episode, EpisodeLog) + e = self._current_episode + row = ( + f"{e.index}/{self._total_episodes}", + f"{e.sim2wall_ratio:.2f}", + e.steps, + f"{e.steps_per_second:.2f}", + e.scenario_map[: self._col_width], + e.scenario_traffic[: self._col_width], + e.mission_hash[: self._col_width], + ) + + score_summaries = [ + f"{score:.2f} - {agent}" for agent, score in e.scores.items() + ] + + if len(score_summaries) == 0: + self._table(row + ("",)) + else: + self._table(row + (score_summaries[0],)) + if len(score_summaries) > 1: + for s in score_summaries[1:]: + self._table(("", "", "", "", "", "", "", s)) + + def __enter__(self): + self._table.__enter__() + return self + + def __exit__(self, *exc): + self._table.__exit__(*exc) + + @staticmethod + def context(col_width): + """Generate a formatted table context object.""" + + return tp.TableContext( + [ + "Episode", + "Sim T / Wall T", + "Total Steps", + "Steps / Sec", + "Scenario Map", + "Scenario Routes", + "Mission (Hash)", + "Scores", + ], + width=col_width, + style="round", + ) + + @dataclass class EpisodeLog: """An episode logging tool.""" @@ -93,43 +166,47 @@ def episodes(n): Acts similar to python's `range(n)` but yielding episode loggers. """ col_width = 18 - with tp.TableContext( - [ - "Episode", - "Sim T / Wall T", - "Total Steps", - "Steps / Sec", - "Scenario Map", - "Scenario Traffic", - "Mission (Hash)", - "Scores", - ], - width=col_width, - style="round", - ) as table: - for i in range(n): - e = EpisodeLog(i) - yield e - - row = ( - f"{e.index}/{n}", - f"{e.sim2wall_ratio:.2f}", - e.steps, - f"{e.steps_per_second:.2f}", - e.scenario_map[:col_width], - e.scenario_traffic[:col_width], - e.mission_hash[:col_width], - ) + with EpisodeLogs(col_width, n) as episode_logs: + for _ in range(n): + yield episode_logs.reset() + episode_logs.reset() - score_summaries = [ - f"{score:.2f} - {agent}" for agent, score in e.scores.items() - ] - if len(score_summaries) == 0: - table(row + ("",)) - continue +@dataclass +class Episodes: + """An episode counter utility.""" - table(row + (score_summaries[0],)) - if len(score_summaries) > 1: - for s in score_summaries[1:]: - table(("", "", "", "", "", "", "", s)) + max_steps: int + current_step: int = 0 + + def __enter__(self): + return self + + def __exit__(self, *exception): + pass + + +class Episode: + """An episode recording object""" + + def __init__(self, episodes: Episodes): + self._episodes = episodes + + def continues(self, observation, reward, done, info) -> bool: + """Determine if the current episode can continue.""" + + self._episodes.current_step += 1 + + if self._episodes.current_step >= self._episodes.max_steps: + return False + if isinstance(done, dict): + return not done.get("__all__", all(done.values())) + return not done + + +def episode_range(max_steps): + """An iteration method that provides a range of episodes that meets the given max steps.""" + + with Episodes(max_steps=max_steps) as episodes: + while episodes.current_step < episodes.max_steps: + yield Episode(episodes=episodes) diff --git a/smarts/env/hiway_env.py b/smarts/env/hiway_env.py index d2f7b1d560..c6ef128b97 100644 --- a/smarts/env/hiway_env.py +++ b/smarts/env/hiway_env.py @@ -138,6 +138,8 @@ def __init__( ), ) + self._env_renderer = None + visdom_client = None if visdom: visdom_client = VisdomClient() @@ -247,6 +249,9 @@ def step( observations[agent_id] = agent_spec.observation_adapter(observation) infos[agent_id] = agent_spec.info_adapter(observation, reward, info) + if self._env_renderer is not None: + self._env_renderer.step(observations, rewards, dones, infos) + for done in dones.values(): self._dones_registered += 1 if done else 0 @@ -269,12 +274,21 @@ def reset(self) -> Dict[str, Observation]: agent_id: self._agent_specs[agent_id].observation_adapter(obs) for agent_id, obs in env_observations.items() } + if self._env_renderer is not None: + self._env_renderer.reset(observations) return observations def render(self, mode="human"): - """Does nothing.""" - pass + """Renders according to metadata requirements.""" + + if "rgb_array" in self.metadata["render.modes"]: + if self._env_renderer is None: + from smarts.env.utils.record import AgentCameraRGBRender + + self._env_renderer = AgentCameraRGBRender(self) + + return self._env_renderer.render(env=self) def close(self): """Closes the environment and releases all resources.""" diff --git a/smarts/env/wrappers/episode_logger.py b/smarts/env/wrappers/episode_logger.py new file mode 100644 index 0000000000..bfbc6ebd5a --- /dev/null +++ b/smarts/env/wrappers/episode_logger.py @@ -0,0 +1,68 @@ +# Copyright (C) 2022. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from typing import Any, Dict, Iterator, Tuple + +import gym + +from smarts.core.utils.episodes import EpisodeLog, EpisodeLogs + +Action = Any +Operation = Any + + +class EpisodeLogger(gym.Wrapper): + """Wraps a gym environment with simple episode logging capabilities.""" + + def __init__(self, env: gym.Env, col_width: int = 18): + super(EpisodeLogger, self).__init__(env) + self._current_episode = None + self._closed = False + self._log_iter = self._episode_logs(col_width) + + def step(self, action: Action) -> Tuple[Operation, float, bool, Dict[str, Any]]: + """Mark a step for logging.""" + + step_vals = super().step(action) + self._current_episode.record_step(*step_vals) + return step_vals + + def reset(self) -> Any: + """Mark an episode reset for logging.""" + + obs = super().reset() + self._current_episode: EpisodeLog = next(self._log_iter) + self._current_episode.record_scenario(self.scenario_log) + return obs + + def close(self): + """Cap off the episode logging.""" + + self._closed = True + try: + next(self._log_iter) + except: + pass + return super().close() + + def _episode_logs(self, col_width) -> Iterator[EpisodeLog]: + with EpisodeLogs(col_width) as episode_logs: + while not self._closed: + yield episode_logs.reset() + episode_logs.reset() diff --git a/smarts/env/wrappers/record_video.py b/smarts/env/wrappers/record_video.py new file mode 100644 index 0000000000..7a5bfe23cd --- /dev/null +++ b/smarts/env/wrappers/record_video.py @@ -0,0 +1,168 @@ +# MIT License +# +# Copyright (C) 2021. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# The MIT License +# +# Copyright (c) 2016 OpenAI (https://openai.com) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# pytype: disable=annotation-type-mismatch +import os +from typing import Callable + +import gym +from gym import logger +from gym.wrappers.monitoring import video_recorder + + +def capped_cubic_video_schedule(episode_id): + """Util""" + if episode_id < 1000: + return int(round(episode_id ** (1.0 / 3))) ** 3 == episode_id + else: + return episode_id % 1000 == 0 + + +class RecordVideo(gym.Wrapper): + """A video recording wrapper.""" + + def __init__( + self, + env, + video_folder: str, + episode_trigger: Callable[[int], bool] = None, + step_trigger: Callable[[int], bool] = None, + video_length: int = 0, + name_prefix: str = "rl-video", + ): + super().__init__(env) + + if episode_trigger is None and step_trigger is None: + episode_trigger = capped_cubic_video_schedule + + trigger_count = sum(x is not None for x in [episode_trigger, step_trigger]) + assert trigger_count == 1, "Must specify exactly one trigger" + + self.episode_trigger = episode_trigger + self.step_trigger = step_trigger + self.video_recorder = None + + self.video_folder = os.path.abspath(video_folder) + # Create output folder if needed + if os.path.isdir(self.video_folder): + logger.warn( + f"Overwriting existing videos at {self.video_folder} folder (try specifying a different `video_folder` for the `RecordVideo` wrapper if this is not desired)" + ) + os.makedirs(self.video_folder, exist_ok=True) + + self.name_prefix = name_prefix + self.step_id = 0 + self.video_length = video_length + + self.recording = False + self.recorded_frames = 0 + self.is_vector_env = getattr(env, "is_vector_env", False) + self.episode_id = 0 + + def reset(self, **kwargs): + """Reset.""" + observations = super().reset(**kwargs) + if not self.recording and self._video_enabled(): + self.start_video_recorder() + return observations + + def start_video_recorder(self): + """Start video.""" + self.close_video_recorder() + + video_name = f"{self.name_prefix}-step-{self.step_id}" + if self.episode_trigger: + video_name = f"{self.name_prefix}-episode-{self.episode_id}" + + base_path = os.path.join(self.video_folder, video_name) + self.video_recorder = video_recorder.VideoRecorder( + env=self.env, + base_path=base_path, + metadata={"step_id": self.step_id, "episode_id": self.episode_id}, + ) + + self.video_recorder.capture_frame() + self.recorded_frames = 1 + self.recording = True + + def _video_enabled(self): + if self.step_trigger: + return self.step_trigger(self.step_id) + else: + return self.episode_trigger(self.episode_id) + + def step(self, action): + """Step.""" + observations, rewards, dones, infos = super().step(action) + + # increment steps and episodes + self.step_id += 1 + if not self.is_vector_env: + if dones: + self.episode_id += 1 + elif dones[0]: + self.episode_id += 1 + + if self.recording: + self.video_recorder.capture_frame() + self.recorded_frames += 1 + if self.video_length > 0: + if self.recorded_frames > self.video_length: + self.close_video_recorder() + else: + if not self.is_vector_env: + if dones: + self.close_video_recorder() + elif dones[0]: + self.close_video_recorder() + + elif self._video_enabled(): + self.start_video_recorder() + + return observations, rewards, dones, infos + + def close_video_recorder(self) -> None: + """Ends recording.""" + if self.recording: + self.video_recorder.close() + self.recording = False + self.recorded_frames = 1 + + def close(self): + """Close.""" + self.close_video_recorder() + + def __del__(self): + self.close_video_recorder() + + +# pytype: enable=annotation-type-mismatch diff --git a/smarts/env/wrappers/utils/__init__.py b/smarts/env/wrappers/utils/__init__.py new file mode 100644 index 0000000000..a6a81ed34c --- /dev/null +++ b/smarts/env/wrappers/utils/__init__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2022. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. diff --git a/smarts/env/wrappers/utils/rendering.py b/smarts/env/wrappers/utils/rendering.py new file mode 100644 index 0000000000..9926736301 --- /dev/null +++ b/smarts/env/wrappers/utils/rendering.py @@ -0,0 +1,93 @@ +# Copyright (C) 2022. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import base64 +import glob +from collections import defaultdict +from itertools import groupby +from pathlib import Path +from typing import Dict + +import cv2 +import numpy as np +from PIL import Image + +from smarts.core.utils.logging import isnotebook + + +def flatten_obs(sim_obs): + """Convert the observation into tuples of observations (for purposes of flattening boids).""" + obs = [] + for agent_id, agent_obs in sim_obs.items(): + if agent_obs is None: + continue + elif isinstance(agent_obs, dict): # is_boid_agent + for vehicle_id, vehicle_obs in agent_obs.items(): + obs.append((vehicle_id, vehicle_obs)) + else: + obs.append((agent_id, agent_obs)) + return obs + + +def vis_sim_obs(sim_obs) -> Dict[str, np.ndarray]: + """Convert the observations into format for mp4 video output.""" + vis_images = defaultdict(list) + + for agent_id, agent_obs in flatten_obs(sim_obs): + drivable_area = getattr(agent_obs, "drivable_area_grid_map", None) + if drivable_area is not None: + image = drivable_area.data + image = image[:, :, [0, 0, 0]] + image = image.astype(np.uint8) + vis_images[f"{agent_id}-DrivableAreaGridMap"].append(image) + + ogm = getattr(agent_obs, "occupancy_grid_map", None) + if ogm is not None: + image: np.ndarray = ogm.data + image = image[:, :, [0, 0, 0]] + image = image.astype(np.uint8) + vis_images[f"{agent_id}-OGM"].append(image) + + rgb = getattr(agent_obs, "top_down_rgb", None) + if rgb is not None: + image = rgb.data + image = image.astype(np.uint8) + vis_images[f"{agent_id}-Top-Down-RGB"].append(image) + + return {key: np.array(images) for key, images in vis_images.items()} + + +def show_notebook_videos(path="videos", height="400px", split_html=""): + """Render a video in a python display. Usually for jupyter notebooks.""" + if not isnotebook(): + return + from IPython import display as ipythondisplay + + html = [] + for mp4 in Path(path).glob("*.mp4"): + video_b64 = base64.b64encode(mp4.read_bytes()) + html.append( + """""".format( + mp4, height, video_b64.decode("ascii") + ) + ) + ipythondisplay.display(ipythondisplay.HTML(data=split_html.join(html))) diff --git a/smarts/sstudio/build_scenario.py b/smarts/sstudio/build_scenario.py new file mode 100644 index 0000000000..cb429004d5 --- /dev/null +++ b/smarts/sstudio/build_scenario.py @@ -0,0 +1,125 @@ +# Copyright (C) 2022. Huawei Technologies Co., Ltd. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Callable, Optional + + +def build_single_scenario( + clean: bool, + allow_offset_map: bool, + scenario: str, + log: Optional[Callable[[Any], None]] = None, +): + """Build a scenario.""" + + if clean: + clean_scenario(scenario) + + scenario_root = Path(scenario) + scenario_root_str = str(scenario_root) + + scenario_py = scenario_root / "scenario.py" + if scenario_py.exists(): + _install_requirements(scenario_root, log) + subprocess.check_call([sys.executable, "scenario.py"], cwd=scenario_root) + + from smarts.core.scenario import Scenario + + traffic_histories = Scenario.discover_traffic_histories(scenario_root_str) + # don't shift maps for scenarios with traffic histories since history data must line up with map + shift_to_origin = not allow_offset_map and not bool(traffic_histories) + + map_spec = Scenario.discover_map(scenario_root_str, shift_to_origin=shift_to_origin) + road_map, _ = map_spec.builder_fn(map_spec) + if not road_map: + log( + "No reference to a RoadNetwork file was found in {}, or one could not be created. " + "Please make sure the path passed is a valid Scenario with RoadNetwork file required " + "(or a way to create one) for scenario building.".format(scenario_root_str) + ) + return + + road_map.to_glb(os.path.join(scenario_root, "map.glb")) + + +def clean_scenario(scenario: str): + """Remove all cached scenario files in the given scenario directory.""" + + to_be_removed = [ + "map.glb", + "map_spec.pkl", + "bubbles.pkl", + "missions.pkl", + "flamegraph-perf.log", + "flamegraph.svg", + "flamegraph.html", + "*.rou.xml", + "*.rou.alt.xml", + "social_agents/*", + "traffic/*.rou.xml", + "traffic/*.smarts.xml", + "history_mission.pkl", + "*.shf", + "*-AUTOGEN.net.xml", + ] + p = Path(scenario) + for file_name in to_be_removed: + for f in p.glob(file_name): + # Remove file + f.unlink() + + +def _install_requirements(scenario_root, log: Optional[Callable[[Any], None]] = None): + import importlib.resources as pkg_resources + + requirements_txt = scenario_root / "requirements.txt" + if requirements_txt.exists(): + import zoo.policies + + with pkg_resources.path(zoo.policies, "") as path: + # Serve policies through the static file server, then kill after + # we've installed scenario requirements + pip_index_proc = subprocess.Popen( + ["twistd", "-n", "web", "--path", path], + # Hide output to keep display simple + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + ) + + pip_install_cmd = [ + sys.executable, + "-m", + "pip", + "install", + "-r", + str(requirements_txt), + ] + + if log is not None: + log(f"Installing scenario dependencies via '{' '.join(pip_install_cmd)}'") + + try: + subprocess.check_call(pip_install_cmd, stdout=subprocess.DEVNULL) + finally: + pip_index_proc.terminate() + pip_index_proc.wait() diff --git a/smarts/zoo/registry.py b/smarts/zoo/registry.py index bf0a1522ac..8f7d318167 100644 --- a/smarts/zoo/registry.py +++ b/smarts/zoo/registry.py @@ -72,3 +72,22 @@ def make(locator: str, **kwargs): ), f"Expected make to produce an instance of AgentSpec, got: {agent_spec}" return agent_spec + + +def make_agent(locator: str, **kwargs): + """Create an Agent from the given agent spec locator. + + In order to load a registered AgentSpec it needs to be reachable from a + directory contained in the PYTHONPATH. + + Args: + locator: + A string in the format of 'path.to.file:locator-name' where the path + is in the form `{PYTHONPATH}[n]/path/to/file.py` + kwargs: + Additional arguments to be passed to the constructed class. + """ + + agent_spec = make(locator, kwargs=kwargs) + + return agent_spec.build_agent()