diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5719c5e6..b395378f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,4 +43,4 @@ jobs: - name: Install evolution gym run: pip install -e . - name: Run test - run: xvfb-run python -m unittest tests/test_render.py + run: xvfb-run python -m pytest -s -v -n auto -m lite diff --git a/evogym/envs/base.py b/evogym/envs/base.py index 7d35752e..41ba7333 100644 --- a/evogym/envs/base.py +++ b/evogym/envs/base.py @@ -119,7 +119,9 @@ def close(self) -> None: """ Close the simulation. """ - self.default_viewer.hide_debug_window() + self.default_viewer.close() + del self._default_viewer + del self._sim def get_actuator_indices(self, robot_name: str) -> np.ndarray: """ diff --git a/evogym/simulator/SimulatorCPP/Interface.cpp b/evogym/simulator/SimulatorCPP/Interface.cpp index cbf0bd0d..ee7bbcb9 100644 --- a/evogym/simulator/SimulatorCPP/Interface.cpp +++ b/evogym/simulator/SimulatorCPP/Interface.cpp @@ -582,6 +582,11 @@ void Interface::hide_debug_window() { debug_window_showing = false; } +void Interface::close() { + glfwDestroyWindow(debug_window); + glfwTerminate(); +} + vector Interface::get_debug_window_pos() { int xpos, ypos; diff --git a/evogym/simulator/SimulatorCPP/Interface.h b/evogym/simulator/SimulatorCPP/Interface.h index fa79d631..37a47519 100644 --- a/evogym/simulator/SimulatorCPP/Interface.h +++ b/evogym/simulator/SimulatorCPP/Interface.h @@ -68,6 +68,7 @@ class Interface void show_debug_window(); void hide_debug_window(); + void close(); vector get_debug_window_pos(); GLFWwindow* get_debug_window_ref(); diff --git a/evogym/simulator/SimulatorCPP/PythonBindings.cpp b/evogym/simulator/SimulatorCPP/PythonBindings.cpp index 27fcddaa..80b84145 100644 --- a/evogym/simulator/SimulatorCPP/PythonBindings.cpp +++ b/evogym/simulator/SimulatorCPP/PythonBindings.cpp @@ -21,6 +21,7 @@ PYBIND11_MODULE(simulator_cpp, m) { .def("render", &Interface::render, py::arg("camera"), py::arg("hide_background") = false, py::arg("hide_grid") = false, py::arg("hide_edges") = false, py::arg("hide_boxels") = false, py::arg("dont_clear") = false) .def("show_debug_window", &Interface::show_debug_window) .def("hide_debug_window", &Interface::hide_debug_window) + .def("close", &Interface::close) .def("get_debug_window_pos", &Interface::get_debug_window_pos, py::return_value_policy::copy); py::class_(m, "Sim") diff --git a/evogym/utils.py b/evogym/utils.py index f046a265..8a5b040d 100644 --- a/evogym/utils.py +++ b/evogym/utils.py @@ -57,11 +57,12 @@ def get_uniform(x: int) -> np.ndarray: Return a uniform distribution of a given size. Args: - x (int): size of distribution. + x (int): size of distribution. Must be positive. Returns: np.ndarray: array representing the probability distribution. """ + assert x > 0, f"Invalid size {x} for uniform distribution. Must be positive." return np.ones((x)) / x def draw(pd: np.ndarray) -> int: @@ -69,14 +70,19 @@ def draw(pd: np.ndarray) -> int: Sample from a probability distribution. Args: - pd (np.ndarray): array representing the probability of sampling each element. + pd (np.ndarray): array representing the relative probability of sampling each element. Entries must be non-negative and sum to a non-zero value. Must contain at least one element. Returns: int: sampled index. """ pd_copy = pd.copy() - if (type(pd_copy) != type(np.array([]))): + if not isinstance(pd_copy, np.ndarray): pd_copy = np.array(pd_copy) + + assert pd_copy.size > 0, f"Invalid size {pd_copy.size} for probability distribution. Must contain at least one element." + assert np.all(pd_copy >= 0), f"Invalid probability distribution {pd_copy}. Entries must be non-negative." + assert np.sum(pd_copy) > 0, f"Invalid probability distribution {pd_copy}. Entries must sum to a non-zero value." + pd_copy = pd_copy / pd_copy.sum() rand = random.uniform(0, 1) @@ -88,17 +94,31 @@ def draw(pd: np.ndarray) -> int: def sample_robot( robot_shape: Tuple[int, int], - pd: np.ndarray = None) -> Tuple[np.ndarray, np.ndarray]: + pd: Optional[np.ndarray] = None +) -> Tuple[np.ndarray, np.ndarray]: """ Return a randomly sampled robot of a particular size. Args: robot_shape (Tuple(int, int)): robot shape to sample `(h, w)`. - pd (np.ndarray): `(5,)` array representing the probability of sampling each robot voxel (empty, rigid, soft, h_act, v_act). Defaults to a custom distribution. (default = None) + pd (np.ndarray): `(5,)` array representing the relative probability of sampling each robot voxel (empty, rigid, soft, h_act, v_act). Defaults to a custom distribution. (default = None) Returns: Tuple[np.ndarray, np.ndarray]: randomly sampled (valid) robot voxel array and its associated connections array. + + Throws: + If it is not possible to sample a connected robot with at least one actuator. """ + + h_act, v_act, empty = VOXEL_TYPES['H_ACT'], VOXEL_TYPES['V_ACT'], VOXEL_TYPES['EMPTY'] + + if pd is not None: + assert pd.shape == (5,), f"Invalid probability distribution {pd}. Must have shape (5,)." + if pd[h_act] + pd[v_act] == 0: + raise ValueError(f"Invalid probability distribution {pd}. Must have a non-zero probability of sampling an actuator.") + if sum(pd) - pd[empty] == 0: + raise ValueError(f"Invalid probability distribution {pd}. Must have a non-zero probability of sampling a non-empty voxel.") + done = False while (not done): @@ -219,7 +239,7 @@ def has_actuator(robot: np.ndarray) -> bool: def get_full_connectivity(robot: np.ndarray) -> np.ndarray: """ - Returns a connections array given a connected robot structure. Assumes all adjacent voxels are connected. + Returns a connections array given a structure. Assumes all adjacent voxels are connected. Args: robot (np.ndarray): array specifing the voxel structure of the robot. diff --git a/evogym/viewer.py b/evogym/viewer.py index 14f4c539..5e7b18c4 100644 --- a/evogym/viewer.py +++ b/evogym/viewer.py @@ -134,6 +134,13 @@ def hide_debug_window(self,) -> None: if self._has_init_viewer: self._viewer.hide_debug_window() + def close(self,) -> None: + """ + Close the viewer. + """ + if self._has_init_viewer: + self._viewer.close() + def track_objects(self, *objects: Tuple[str]) -> None: """ Set objects for the viewer to automatically track. The viewer tracks objects by adjusting its `pos` and `view_size` automatically every time before rendering. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..310d7672 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = --ignore=examples/externals --ignore=evogym/simulator/externals --ignore=evogym/examples/gym_test.py +markers = + lite: mark as part of the lite test suite \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1d687450..ee64a479 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,5 @@ PyOpenGL-accelerate==3.1.5 torch==1.10.2 ttkbootstrap==1.5.1 typing==3.7.4.3 +pytest +pytest-xdist diff --git a/tests/requires_screen/test_screen_render_modes.py b/tests/requires_screen/test_screen_render_modes.py new file mode 100644 index 00000000..56583340 --- /dev/null +++ b/tests/requires_screen/test_screen_render_modes.py @@ -0,0 +1,78 @@ +import gymnasium as gym +import pytest +import warnings +import numpy as np +from itertools import product + +import evogym.envs +from evogym import sample_robot + +LITE_TEST_ENV_NAMES = [ + "Pusher-v0", + "Walker-v0", + "Traverser-v0", +] + +@pytest.mark.lite +@pytest.mark.parametrize( + "render_mode, add_options", + list(product( + ["human", "screen"], + [True, False], + )) +) +def test_render_modes(render_mode, add_options): + """ + - Env can render to screen + """ + + body, _ = sample_robot((5, 5)) + if add_options: + env = gym.make("Walker-v0", body=body, render_mode=render_mode, render_options={ + "verbose": False, + "hide_background": False, + "hide_grid": False, + "hide_edges": False, + "hide_voxels": False + }) + else: + env = gym.make("Walker-v0", body=body, render_mode=render_mode) + + # Reset + obs, info = env.reset(seed=None, options=None) + + for i in range(10): + + # Step -- we don't need to render explicitly + action = env.action_space.sample() - 1 + ob, reward, terminated, truncated, info = env.step(action) + + env.close() + +def get_all_env_render_params(): + return [ + env_name if env_name not in LITE_TEST_ENV_NAMES + else pytest.param(env_name, marks=pytest.mark.lite) + for env_name in evogym.BASELINE_ENV_NAMES + ] + +@pytest.mark.parametrize("env_name", get_all_env_render_params()) +def test_all_env_render(env_name): + """ + - Env can render to screen + """ + + body, _ = sample_robot((5, 5)) + env = gym.make(env_name, body=body, render_mode="human") + + # Reset + obs, info = env.reset(seed=None, options=None) + + for i in range(10): + + # Step -- we don't need to render explicitly + action = env.action_space.sample() - 1 + ob, reward, terminated, truncated, info = env.step(action) + + env.close() + \ No newline at end of file diff --git a/tests/screen_free/test_baseline_envs.py b/tests/screen_free/test_baseline_envs.py new file mode 100644 index 00000000..1550a19a --- /dev/null +++ b/tests/screen_free/test_baseline_envs.py @@ -0,0 +1,62 @@ +import gymnasium as gym +import pytest +import warnings + +import evogym.envs +from evogym import sample_robot + +LITE_TEST_ENV_NAMES = [ + "Pusher-v0", + "Walker-v0", + "Traverser-v0", +] + +def get_params(): + return [ + env_name if env_name not in LITE_TEST_ENV_NAMES + else pytest.param(env_name, marks=pytest.mark.lite) + for env_name in evogym.BASELINE_ENV_NAMES + ] + +@pytest.mark.parametrize("env_name", get_params()) +def test_env_creatable_and_has_correct_api(env_name): + """ + - Env is creatable + - Env steps for the correct number of steps + - Env follows the gym API + """ + + body, _ = sample_robot((5, 5)) + env = gym.make(env_name, body=body) + + target_steps = env.spec.max_episode_steps + assert isinstance(target_steps, int), f"Env {env_name} does not have a max_episode_steps attribute" + + # Reset + obs, info = env.reset(seed=None, options=None) + + # Rollout with random actions + n_steps = 0 + while True: + action = env.action_space.sample() - 1 + ob, reward, terminated, truncated, info = env.step(action) + + n_steps += 1 + + if terminated or truncated: + env.reset(seed=None, options=None) + break + + if n_steps > target_steps: + break + + # Make sure we can still step after resetting + env.step(env.action_space.sample() - 1) + + # Check that the env terminated after the correct number of steps + assert n_steps <= target_steps, f"Env {env_name} terminated after {n_steps} steps, expected at most {target_steps}" + if n_steps < target_steps: + warnings.warn(f"Env {env_name} terminated early after {n_steps} steps, expected {target_steps}") + + env.close() + \ No newline at end of file diff --git a/tests/screen_free/test_img_render_modes.py b/tests/screen_free/test_img_render_modes.py new file mode 100644 index 00000000..b42e1e4e --- /dev/null +++ b/tests/screen_free/test_img_render_modes.py @@ -0,0 +1,58 @@ +import gymnasium as gym +import pytest +import warnings +import numpy as np +from itertools import product + +import evogym.envs +from evogym import sample_robot + +LITE_TEST_ENV_NAMES = [ + "Pusher-v0", + "Walker-v0", + "Traverser-v0", +] + +def get_params(): + params = product( + evogym.BASELINE_ENV_NAMES, + [None, "img", "rgb_array"], + ) + return [ + param if param[0] not in LITE_TEST_ENV_NAMES + else pytest.param(*param, marks=pytest.mark.lite) + for param in params + ] + + +@pytest.mark.parametrize("env_name, render_mode", get_params()) +def test_render(env_name, render_mode): + """ + - Env can render to none and to image + """ + + body, _ = sample_robot((5, 5)) + env = gym.make(env_name, body=body, render_mode=render_mode) + + # Reset + obs, info = env.reset(seed=None, options=None) + + for i in range(10): + + # Render + result = env.render() + + if render_mode is None: + # Result should be None + assert result is None, f"Env returned {type(result)} instead of None" + else: + # Check img + assert isinstance(result, np.ndarray), f"Env returned {type(result)} instead of np.ndarray" + x, y, c = result.shape + assert c == 3, f"Env returned image with {c} channels, expected 3" + + # Step + action = env.action_space.sample() - 1 + ob, reward, terminated, truncated, info = env.step(action) + + env.close() \ No newline at end of file diff --git a/tests/screen_free/test_utils.py b/tests/screen_free/test_utils.py new file mode 100644 index 00000000..481d0963 --- /dev/null +++ b/tests/screen_free/test_utils.py @@ -0,0 +1,214 @@ +import numpy as np +import pytest +from pytest import raises +from typing import List, Tuple + +from evogym.utils import ( + VOXEL_TYPES, + get_uniform, draw, sample_robot, + is_connected, has_actuator, get_full_connectivity +) + +@pytest.mark.lite +def test_get_uniform(): + ones = get_uniform(1) + assert np.allclose(ones, np.ones(1)), ( + f"Expected {np.ones(1)}, got {ones}" + ) + + one_thirds = get_uniform(3) + assert np.allclose(one_thirds, np.ones(3) / 3), ( + f"Expected {np.ones(3) / 3}, got {one_thirds}" + ) + +@pytest.mark.lite +def test_draw(): + result = draw([0.2]) + assert result == 0, f"Expected 0, got {result}" + + result = draw([0.2, 0]) + assert result == 0, f"Expected 0, got {result}" + + result = draw([0, 15]) + assert result == 1, f"Expected 1, got {result}" + + pd = np.zeros(10) + pd[5] = 1 + result = draw(pd) + assert result == 5, f"Expected 5, got {result}" + + pd = np.ones(10) + for i in range(10): + result = draw(pd) + assert result in list(range(10)), f"Expected result to be between 0 and 9, got {result}" + +@pytest.mark.lite +def test_has_actuator(): + h_act, v_act = VOXEL_TYPES['H_ACT'], VOXEL_TYPES['V_ACT'] + others = [ + i for i in VOXEL_TYPES.values() if i not in [h_act, v_act] + ] + + robot = np.zeros((1, 1)) + robot[:, :] = others[0] + assert not has_actuator(robot), "Expected no actuator" + + robot[:, :] = h_act + assert has_actuator(robot), "Expected actuator" + + robot[:, :] = v_act + assert has_actuator(robot), "Expected actuator" + + robot = np.random.choice(others, (10, 10), replace=True) + assert not has_actuator(robot), "Expected no actuator" + + robot[5, 5] = h_act + assert has_actuator(robot), "Expected actuator" + + robot[5, 5] = v_act + assert has_actuator(robot), "Expected actuator" + + robot[1, 1] = h_act + assert has_actuator(robot), "Expected actuator" + + robot = np.random.choice([h_act, v_act], (10, 10), replace=True) + assert has_actuator(robot), "Expected actuator" + +def test_is_connected(): + empty = VOXEL_TYPES['EMPTY'] + others = [ + i for i in VOXEL_TYPES.values() if i != empty + ] + + robot = np.zeros((1, 1)) + robot[:, :] = empty + assert not is_connected(robot), "Expected not connected" + + for val in others: + robot[:, :] = val + assert is_connected(robot), "Expected connected" + + robot = np.array([[others[0]], [empty], [others[1]]]) + assert not is_connected(robot), "Expected not connected" + assert not is_connected(robot.T), "Expected not connected" + + robot = np.array([ + [others[0], empty, others[0]], + [others[1], empty, others[3]], + [others[2], others[1], others[0]] + ]) + assert is_connected(robot), "Expected connected" + assert is_connected(robot.T), "Expected connected" + + robot = np.array([ + [empty, empty, empty], + [empty, others[2], empty], + [empty, empty, empty] + ]) + assert is_connected(robot), "Expected connected" + + robot = np.array([ + [others[0], others[1], empty], + [others[1], empty, others[1]], + [empty, others[1], others[0]] + ]) + assert not is_connected(robot), "Expected not connected" + +@pytest.mark.lite +def test_get_full_connectivity(): + empty = VOXEL_TYPES['EMPTY'] + others = [ + i for i in VOXEL_TYPES.values() if i != empty + ] + + robot = np.zeros((1, 1)) + robot[:, :] = empty + assert get_full_connectivity(robot).shape[1] == 0, "Expected no connections" + assert get_full_connectivity(robot).shape[0] == 2, "Expected 2" + + robot[:, :] = others[0] + assert get_full_connectivity(robot).shape[1] == 0, "Expected no connections" + assert get_full_connectivity(robot).shape[0] == 2, "Expected 2" + + robot = np.array([[others[0], empty, others[0]]]) + connections = get_full_connectivity(robot) + assert connections.shape[1] == 0, "Expected no connections" + + def connections_contains_all(connections: np.ndarray, expected: List[Tuple[int, int]]): + connections_as_tuples = [ + (c[0], c[1]) for c in connections.T + ] + for i, j in expected: + if (i, j) not in connections_as_tuples or (j, i) not in connections_as_tuples: + return False + return True + + robot = np.array([ + [others[0], empty, others[0]], + [others[1], empty, others[1]], + ]) + connections = get_full_connectivity(robot) + assert connections.shape[1] == 2, "Expected 2 connections" + assert connections.shape[0] == 2, "Expected 2" + connections_contains_all(connections, [(0, 3), (2, 5)]) + + + robot = np.array([ + [others[0], others[2], empty], + [empty, others[3], others[1]], + ]) + connections = get_full_connectivity(robot) + assert connections.shape[1] == 3, "Expected 2 connections" + assert connections.shape[0] == 2, "Expected 2" + connections_contains_all(connections, [(0, 1), (1, 4), (4, 5)]) + +@pytest.mark.lite +def test_sample_robot(): + + h_act, v_act, empty = VOXEL_TYPES['H_ACT'], VOXEL_TYPES['V_ACT'], VOXEL_TYPES['EMPTY'] + + bad_pd = np.ones(5) + bad_pd[h_act] = 0 + bad_pd[v_act] = 0 + with raises(Exception): + sample_robot((5,5), bad_pd) + + bad_pd = np.zeros(5) + bad_pd[empty] = 1 + with raises(Exception): + sample_robot((5,5), bad_pd) + + def check_robot(robot: np.ndarray, connections: np.ndarray): + assert robot.shape == (5, 5), f"Expected shape (5, 5), got {robot.shape}" + assert is_connected(robot), "Expected robot to be connected" + assert has_actuator(robot), "Expected robot to have an actuator" + assert np.allclose(get_full_connectivity(robot), connections), "Expected connections to be the same" + + robot, connections = sample_robot((5, 5)) + check_robot(robot, connections) + + pd = np.ones(5) + pd[h_act] = 0 + robot, connections = sample_robot((5, 5), pd=pd) + check_robot(robot, connections) + + pd = np.ones(5) + pd[v_act] = 0 + robot, connections = sample_robot((5, 5), pd=pd) + check_robot(robot, connections) + + pd = np.ones(5) + pd[empty] = 0 + robot, connections = sample_robot((5, 5), pd=pd) + check_robot(robot, connections) + + pd = np.zeros(5) + pd[v_act] = 1 + robot, connections = sample_robot((5, 5), pd=pd) + check_robot(robot, connections) + + pd = np.zeros(5) + pd[h_act] = 1 + robot, connections = sample_robot((5, 5), pd=pd) + check_robot(robot, connections) + \ No newline at end of file diff --git a/tests/test_render.py b/tests/test_render.py deleted file mode 100644 index 7ec26227..00000000 --- a/tests/test_render.py +++ /dev/null @@ -1,21 +0,0 @@ -from unittest import TestCase - -import evogym.envs -import gymnasium as gym -from evogym import sample_robot - - -class RenderTest(TestCase): - def test_it(self): - body, connections = sample_robot((5, 5)) - env = gym.make("Walker-v0", body=body, render_mode="human") - env.reset() - - for _ in range(100): - action = env.action_space.sample() - 1 - ob, reward, terminated, truncated, info = env.step(action) - - if terminated or truncated: - env.reset() - - env.close() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..53b1b913 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,5 @@ +LITE_TEST_ENV_NAMES = [ + "Pusher-v0", + "Walker-v0", + "Traverser-v0", +] \ No newline at end of file