From f11c797a69ef0aab69f3b868b9831a90b4ec7bd6 Mon Sep 17 00:00:00 2001 From: tviskaron Date: Mon, 23 Feb 2026 15:45:52 +0300 Subject: [PATCH 01/15] Migrate to gymnasium >= 1.0 and modernize build tooling - Use `env.unwrapped` for pogema-specific attributes (gymnasium >= 1.0 removed Wrapper.__getattr__ forwarding) - Replace flake8 with ruff, migrate CI to uv - Remove stale setup.py, requirements.txt, build.sh - Misc pyproject.toml cleanup (numpy bound, classifiers, requires-python) --- .github/workflows/CI.yml | 26 ++--- build.sh | 3 - local_build.sh | 1 - pogema/envs.py | 2 +- pogema/grid_config.py | 133 +++++++++++++--------- pogema/integrations/make_pogema.py | 2 +- pogema/integrations/pettingzoo.py | 6 +- pogema/integrations/pymarl.py | 10 +- pogema/integrations/sample_factory.py | 2 +- pogema/svg_animation/animation_wrapper.py | 23 ++-- pogema/wrappers/metrics.py | 22 ++-- pogema/wrappers/multi_time_limit.py | 4 +- pogema/wrappers/persistence.py | 20 ++-- pyproject.toml | 53 +++++++++ requirements.txt | 6 - setup.py | 53 --------- tests/test_grid.py | 4 +- tests/test_integrations.py | 6 +- tests/test_pogema_env.py | 12 +- 19 files changed, 198 insertions(+), 190 deletions(-) delete mode 100644 build.sh delete mode 100644 local_build.sh create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 7e9132b..b04db17 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -16,25 +16,17 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + run: uv sync --extra test --extra dev + - name: Lint with ruff + run: uv run ruff check . - name: Test with pytest - run: | - PYTHONPATH=. pytest -s + run: uv run pytest -s diff --git a/build.sh b/build.sh deleted file mode 100644 index b1024db..0000000 --- a/build.sh +++ /dev/null @@ -1,3 +0,0 @@ -rm -rf dist/ -python3 -m build -python3 -m twine upload dist/* diff --git a/local_build.sh b/local_build.sh deleted file mode 100644 index ddcd346..0000000 --- a/local_build.sh +++ /dev/null @@ -1 +0,0 @@ -pip3 install --no-cache-dir . \ No newline at end of file diff --git a/pogema/envs.py b/pogema/envs.py index c7c5d74..dd4d1c9 100644 --- a/pogema/envs.py +++ b/pogema/envs.py @@ -464,7 +464,7 @@ def _make_pogema(grid_config): raise KeyError(f'Unknown on_target option: {grid_config.on_target}') env = MultiTimeLimit(env, grid_config.max_episode_steps) - if env.grid_config.persistent: + if grid_config.persistent: env = PersistentWrapper(env) else: # adding metrics wrappers diff --git a/pogema/grid_config.py b/pogema/grid_config.py index 77cf63e..ff18c23 100644 --- a/pogema/grid_config.py +++ b/pogema/grid_config.py @@ -1,13 +1,20 @@ import sys from typing import Optional, Union from pydantic import validator, root_validator +from pydantic import BaseModel, model_validator from pogema.utils import CommonSettings from typing_extensions import Literal +import sys +from typing import Optional, Union +from pydantic import validator, model_validator +from pogema.utils import CommonSettings +from typing_extensions import Literal -class GridConfig(CommonSettings, ): + +class GridConfig(CommonSettings): on_target: Literal['finish', 'nothing', 'restart'] = 'finish' seed: Optional[int] = None width: Optional[int] = None @@ -24,52 +31,69 @@ class GridConfig(CommonSettings, ): persistent: bool = False observation_type: Literal['POMAPF', 'MAPF', 'default'] = 'default' map: Optional[Union[list, str]] = None - map_name: Optional[str] = None - - integration: Literal['SampleFactory', 'PyMARL', 'rllib', 'gymnasium', 'PettingZoo'] = None + integration: Optional[Literal['SampleFactory', 'PyMARL', 'rllib', 'gymnasium', 'PettingZoo']] = None max_episode_steps: int = 64 auto_reset: Optional[bool] = None - @root_validator - def validate_dimensions_and_positions(cls, values): - width_provided = values.get('width') is not None - height_provided = values.get('height') is not None - + @model_validator(mode='after') + def validate_dimensions_and_positions(cls, model): + # Use getattr for safe access, with default fallback + width = getattr(model, 'width', None) + height = getattr(model, 'height', None) + size = getattr(model, 'size', 8) + + width_provided = width is not None and width > 0 + height_provided = height is not None and height > 0 + if width_provided and not height_provided: - raise ValueError("Invalid dimension configuration. Please provide height.") - elif not width_provided and height_provided: - raise ValueError("Invalid dimension configuration. Please provide width.") - + raise ValueError("Invalid dimension configuration: width provided but height missing.") + if height_provided and not width_provided: + raise ValueError("Invalid dimension configuration: height provided but width missing.") + if not width_provided and not height_provided: - values['width'] = values.get('size', 8) - values['height'] = values.get('size', 8) - if 'size' not in values or values.get('size') != max(values.get('width'), values.get('height')): - values['size'] = max(values.get('width'), values.get('height')) - - - width = values.get('width') - height = values.get('height') - - if width is not None and height is not None: - agents_xy = values.get('agents_xy') - if agents_xy is not None: - cls.check_positions(agents_xy, width, height) - - targets_xy = values.get('targets_xy') - if targets_xy is not None: - first_element = targets_xy[0] - if isinstance(first_element[0], (list, tuple)): - for agent_goals in targets_xy: - cls.check_positions(agent_goals, width, height) - else: - cls.check_positions(targets_xy, width, height) - - return values + fallback_size = size if size >= 2 else 8 + width = fallback_size + height = fallback_size + + if width <= 0: + width = 8 + if height <= 0: + height = 8 + + size = max(width, height, 2) + + setattr(model, 'width', width) + setattr(model, 'height', height) + setattr(model, 'size', size) + + if not (1 <= width <= 4096): + raise ValueError(f"width must be in [1, 4096], got {width}") + if not (1 <= height <= 4096): + raise ValueError(f"height must be in [1, 4096], got {height}") + if not (2 <= size <= 4096): + raise ValueError(f"size must be in [2, 4096], got {size}") + + # Validate positions + agents_xy = getattr(model, 'agents_xy', None) + targets_xy = getattr(model, 'targets_xy', None) + + if agents_xy is not None: + cls.check_positions(agents_xy, width, height) + + if targets_xy is not None: + first_element = targets_xy[0] + if isinstance(first_element[0], (list, tuple)): + for agent_goals in targets_xy: + cls.check_positions(agent_goals, width, height) + else: + cls.check_positions(targets_xy, width, height) + + return model @validator('seed') def seed_initialization(cls, v): - assert v is None or (0 <= v < sys.maxsize), "seed must be in [0, " + str(sys.maxsize) + ']' + assert v is None or (0 <= v < sys.maxsize), f"seed must be in [0, {sys.maxsize}]" return v @staticmethod @@ -99,7 +123,7 @@ def density_restrictions(cls, v): return v @validator('agents_xy') - def agents_xy_validation(cls, v, values): + def agents_xy_validation(cls, v): if v is not None: if not isinstance(v, (list, tuple)): raise ValueError("agents_xy must be a list") @@ -115,11 +139,11 @@ def targets_xy_validation(cls, v, values): if v is not None: if not v or not isinstance(v, (list, tuple)): raise ValueError("targets_xy must be a list") - + first_element = v[0] if not isinstance(first_element, (list, tuple)): raise ValueError("Invalid targets_xy format") - + if isinstance(first_element[0], (list, tuple)): for agent_goals in v: if not isinstance(agent_goals, (list, tuple)) or len(agent_goals) < 2: @@ -132,7 +156,10 @@ def targets_xy_validation(cls, v, values): else: on_target = values.get('on_target', 'finish') if on_target == 'restart': - raise ValueError("on_target='restart' requires goal sequences, not single goals. Use format: targets_xy: [[[x1,y1],[x2,y2]], [[x3,y3],[x4,y4]]]") + raise ValueError( + "on_target='restart' requires goal sequences, not single goals. " + "Use format: targets_xy: [[[x1,y1],[x2,y2]], [[x3,y3],[x4,y4]]]" + ) for position in v: if not isinstance(position, (list, tuple)) or len(position) != 2: raise ValueError("Position must be a list/tuple of length 2") @@ -188,14 +215,14 @@ def map_validation(cls, v, values): 'targets_xy') is None) and possible_agents_xy and possible_targets_xy: values['possible_agents_xy'] = possible_agents_xy values['possible_targets_xy'] = possible_targets_xy - + height = len(v) width = 0 area = 0 for line in v: width = max(width, len(line)) area += len(line) - + values['size'] = max(width, height) values['width'] = width values['height'] = height @@ -203,13 +230,13 @@ def map_validation(cls, v, values): return v - @validator('possible_agents_xy') - def possible_agents_xy_validation(cls, v): - return v - - @validator('possible_targets_xy') - def possible_targets_xy_validation(cls, v): - return v + # @validator('possible_agents_xy') + # def possible_agents_xy_validation(cls, v): + # return v + # + # @validator('possible_targets_xy') + # def possible_targets_xy_validation(cls, v): + # return v @staticmethod def str_map_to_list(str_map, free, obstacle): @@ -266,7 +293,7 @@ def str_map_to_list(str_map, free, obstacle): def update_config(self, **kwargs): current_values = self.dict() - + if 'size' in kwargs: current_values.pop('width', None) current_values.pop('height', None) @@ -274,6 +301,6 @@ def update_config(self, **kwargs): current_values.pop('size', None) current_values.update(kwargs) new_instance = GridConfig(**current_values) - + for field_name, field_value in new_instance.__dict__.items(): setattr(self, field_name, field_value) diff --git a/pogema/integrations/make_pogema.py b/pogema/integrations/make_pogema.py index 83a8b46..9e64901 100644 --- a/pogema/integrations/make_pogema.py +++ b/pogema/integrations/make_pogema.py @@ -26,7 +26,7 @@ class SingleAgentWrapper(Wrapper): def step(self, action): observations, rewards, terminated, truncated, infos = self.env.step( - [action] + [self.env.action_space.sample() for _ in range(self.get_num_agents() - 1)]) + [action] + [self.env.action_space.sample() for _ in range(self.unwrapped.get_num_agents() - 1)]) return observations[0], rewards[0], terminated[0], truncated[0], infos[0] def reset(self, seed: Optional[int] = None, return_info: bool = True, options: Optional[dict] = None, ): diff --git a/pogema/integrations/pettingzoo.py b/pogema/integrations/pettingzoo.py index 41a8792..b2166bd 100644 --- a/pogema/integrations/pettingzoo.py +++ b/pogema/integrations/pettingzoo.py @@ -12,13 +12,13 @@ def parallel_env(grid_config: GridConfig = GridConfig()): class PogemaParallel: def state(self): - return self.pogema.get_state() + return self.pogema.unwrapped.get_state() def __init__(self, grid_config: GridConfig, render_mode='ansi'): self.metadata = {'render_modes': ['ansi'], "name": "pogema"} self.render_mode = render_mode self.pogema = _make_pogema(grid_config) - self.possible_agents = ["player_" + str(r) for r in range(self.pogema.get_num_agents())] + self.possible_agents = ["player_" + str(r) for r in range(self.pogema.unwrapped.get_num_agents())] self.agent_name_mapping = dict(zip(self.possible_agents, list(range(len(self.possible_agents))))) self.agents = None self.num_moves = None @@ -58,7 +58,7 @@ def step(self, actions): d_infos = {agent: infos[anm[agent]] for agent in self.agents} for agent, idx in anm.items(): - if (not self.pogema.grid.is_active[idx] or all(truncated) or all(terminated)) and agent in self.agents: + if (not self.pogema.unwrapped.grid.is_active[idx] or all(truncated) or all(terminated)) and agent in self.agents: self.agents.remove(agent) return d_observations, d_rewards, d_terminated, d_truncated, d_infos diff --git a/pogema/integrations/pymarl.py b/pogema/integrations/pymarl.py index 5a9ec87..f28d6e2 100644 --- a/pogema/integrations/pymarl.py +++ b/pogema/integrations/pymarl.py @@ -15,7 +15,7 @@ def __init__(self, grid_config, mh_distance=False): self._observations, _ = self.env.reset() self.max_episode_steps = gc.max_episode_steps self.episode_limit = gc.max_episode_steps - self.n_agents = self.env.get_num_agents() + self.n_agents = self.env.unwrapped.get_num_agents() self.spec = None @@ -43,14 +43,14 @@ def get_obs_size(self): return len(np.array(self._observations[0]).flatten()) def get_state(self): - return self.env.get_state() + return self.env.unwrapped.get_state() def get_state_size(self): return len(self.get_state()) def get_avail_actions(self): actions = [] - for i in range(self.env.get_num_agents()): + for i in range(self.env.unwrapped.get_num_agents()): actions.append(self.get_avail_agent_actions(i)) return actions @@ -64,7 +64,7 @@ def get_total_actions(): return 5 def reset(self): - self._grid_config = self.env.grid_config + self._grid_config = self.env.unwrapped.grid_config self._observations, _ = self.env.reset() return np.array(self._observations).flatten() @@ -91,4 +91,4 @@ def close(self): return def sample_actions(self): - return self.env.sample_actions() + return self.env.unwrapped.sample_actions() diff --git a/pogema/integrations/sample_factory.py b/pogema/integrations/sample_factory.py index 1152aff..94b6de9 100644 --- a/pogema/integrations/sample_factory.py +++ b/pogema/integrations/sample_factory.py @@ -11,7 +11,7 @@ def __init__(self, env): @property def num_agents(self): - return self.get_num_agents() + return self.unwrapped.get_num_agents() class MetricsForwardingWrapper(Wrapper): diff --git a/pogema/svg_animation/animation_wrapper.py b/pogema/svg_animation/animation_wrapper.py index adaa88d..ed7aa33 100644 --- a/pogema/svg_animation/animation_wrapper.py +++ b/pogema/svg_animation/animation_wrapper.py @@ -1,6 +1,6 @@ import os from itertools import cycle -from gymnasium import logger, Wrapper +from gymnasium import Wrapper from pogema import GridConfig from pogema.svg_animation.animation_drawer import AnimationConfig, SvgSettings, GridHolder, AnimationDrawer @@ -13,7 +13,7 @@ class AnimationMonitor(Wrapper): """ def __init__(self, env, animation_config=AnimationConfig()): - self._working_radius = env.grid_config.obs_radius - 1 + self._working_radius = env.unwrapped.grid_config.obs_radius - 1 env = PersistentWrapper(env, xy_offset=-self._working_radius) super().__init__(env) @@ -43,11 +43,10 @@ def step(self, action): if save_tau: if (self._episode_idx + 1) % save_tau or save_tau == 1: if not os.path.exists(self.animation_config.directory): - logger.info(f"Creating pogema monitor directory {self.animation_config.directory}", ) os.makedirs(self.animation_config.directory, exist_ok=True) path = os.path.join(self.animation_config.directory, - self.pick_name(self.grid_config, self._episode_idx)) + self.pick_name(self.unwrapped.grid_config, self._episode_idx)) self.save_animation(path) return obs, reward, terminated, truncated, info @@ -96,23 +95,23 @@ def save_animation(self, name='render.svg', animation_config: AnimationConfig = """ wr = self._working_radius if wr > 0: - obstacles = self.env.get_obstacles(ignore_borders=False)[wr:-wr, wr:-wr] + obstacles = self.unwrapped.get_obstacles(ignore_borders=False)[wr:-wr, wr:-wr] else: - obstacles = self.env.get_obstacles(ignore_borders=False) + obstacles = self.unwrapped.get_obstacles(ignore_borders=False) history: list[list[AgentState]] = self.env.decompress_history(self.history) svg_settings = SvgSettings() colors_cycle = cycle(svg_settings.colors) - agents_colors = {index: next(colors_cycle) for index in range(self.grid_config.num_agents)} + agents_colors = {index: next(colors_cycle) for index in range(self.unwrapped.grid_config.num_agents)} - for agent_idx in range(self.grid_config.num_agents): + for agent_idx in range(self.unwrapped.grid_config.num_agents): history[agent_idx].append(history[agent_idx][-1]) episode_length = len(history[0]) # Change episode length for egocentric environment - if animation_config.egocentric_idx is not None and self.grid_config.on_target == 'finish': + if animation_config.egocentric_idx is not None and self.unwrapped.grid_config.on_target == 'finish': episode_length = history[animation_config.egocentric_idx][-1].step + 1 - for agent_idx in range(self.grid_config.num_agents): + for agent_idx in range(self.unwrapped.grid_config.num_agents): history[agent_idx] = history[agent_idx][:episode_length] grid_holder = GridHolder( @@ -120,8 +119,8 @@ def save_animation(self, name='render.svg', animation_config: AnimationConfig = obstacles=obstacles, episode_length=episode_length, history=history, - obs_radius=self.grid_config.obs_radius, - on_target=self.grid_config.on_target, + obs_radius=self.unwrapped.grid_config.obs_radius, + on_target=self.unwrapped.grid_config.on_target, colors=agents_colors, config=animation_config, svg_settings=svg_settings diff --git a/pogema/wrappers/metrics.py b/pogema/wrappers/metrics.py index c684411..6d09c19 100644 --- a/pogema/wrappers/metrics.py +++ b/pogema/wrappers/metrics.py @@ -16,7 +16,7 @@ def step(self, action): obs, reward, terminated, truncated, infos = self.env.step(action) finished = all(truncated) or all(terminated) - metric = self._compute_stats(self._current_step, self.was_on_goal, finished) + metric = self._compute_stats(self._current_step, self.unwrapped.was_on_goal, finished) self._current_step += 1 if finished: self._current_step = 0 @@ -40,7 +40,7 @@ def _compute_stats(self, step, is_on_goal, finished): if on_goal: self._solved_instances += 1 if finished: - result = {'avg_throughput': self._solved_instances / self.grid_config.max_episode_steps} + result = {'avg_throughput': self._solved_instances / self.unwrapped.grid_config.max_episode_steps} self._solved_instances = 0 return result @@ -56,7 +56,7 @@ class NonDisappearISRMetric(AbstractMetric): def _compute_stats(self, step, is_on_goal, finished): if finished: - return {'ISR': float(sum(is_on_goal)) / self.get_num_agents()} + return {'ISR': float(sum(is_on_goal)) / self.unwrapped.get_num_agents()} class NonDisappearEpLengthMetric(AbstractMetric): @@ -69,7 +69,7 @@ def _compute_stats(self, step, is_on_goal, finished): class EpLengthMetric(AbstractMetric): def __init__(self, env): super().__init__(env) - self._solve_time = [None for _ in range(self.get_num_agents())] + self._solve_time = [None for _ in range(self.unwrapped.get_num_agents())] def _compute_stats(self, step, is_on_goal, finished): for idx, on_goal in enumerate(is_on_goal): @@ -78,8 +78,8 @@ def _compute_stats(self, step, is_on_goal, finished): self._solve_time[idx] = step if finished: - result = {'ep_length': sum(self._solve_time) / self.get_num_agents() + 1} - self._solve_time = [None for _ in range(self.get_num_agents())] + result = {'ep_length': sum(self._solve_time) / self.unwrapped.get_num_agents() + 1} + self._solve_time = [None for _ in range(self.unwrapped.get_num_agents())] return result @@ -91,7 +91,7 @@ def __init__(self, env): def _compute_stats(self, step, is_on_goal, finished): self._solved_instances += sum(is_on_goal) if finished: - results = {'CSR': float(self._solved_instances == self.get_num_agents())} + results = {'CSR': float(self._solved_instances == self.unwrapped.get_num_agents())} self._solved_instances = 0 return results @@ -104,7 +104,7 @@ def __init__(self, env): def _compute_stats(self, step, is_on_goal, finished): self._solved_instances += sum(is_on_goal) if finished: - results = {'ISR': self._solved_instances / self.get_num_agents()} + results = {'ISR': self._solved_instances / self.unwrapped.get_num_agents()} self._solved_instances = 0 return results @@ -112,7 +112,7 @@ def _compute_stats(self, step, is_on_goal, finished): class SumOfCostsAndMakespanMetric(AbstractMetric): def __init__(self, env): super().__init__(env) - self._solve_time = [None for _ in range(self.get_num_agents())] + self._solve_time = [None for _ in range(self.unwrapped.get_num_agents())] def _compute_stats(self, step, is_on_goal, finished): for idx, on_goal in enumerate(is_on_goal): @@ -122,8 +122,8 @@ def _compute_stats(self, step, is_on_goal, finished): self._solve_time[idx] = None if finished: - result = {'SoC': sum(self._solve_time) + self.get_num_agents(), 'makespan': max(self._solve_time) + 1} - self._solve_time = [None for _ in range(self.get_num_agents())] + result = {'SoC': sum(self._solve_time) + self.unwrapped.get_num_agents(), 'makespan': max(self._solve_time) + 1} + self._solve_time = [None for _ in range(self.unwrapped.get_num_agents())] return result diff --git a/pogema/wrappers/multi_time_limit.py b/pogema/wrappers/multi_time_limit.py index b120f71..13c9cc9 100644 --- a/pogema/wrappers/multi_time_limit.py +++ b/pogema/wrappers/multi_time_limit.py @@ -6,11 +6,11 @@ def step(self, action): observation, reward, terminated, truncated, info = self.env.step(action) self._elapsed_steps += 1 if self._elapsed_steps >= self._max_episode_steps: - truncated = [True] * self.get_num_agents() + truncated = [True] * self.unwrapped.get_num_agents() return observation, reward, terminated, truncated, info def set_elapsed_steps(self, elapsed_steps): - if not self.grid_config.persistent: + if not self.unwrapped.grid_config.persistent: raise ValueError("Cannot set elapsed steps for non-persistent environment!") assert elapsed_steps >= 0 self._elapsed_steps = elapsed_steps diff --git a/pogema/wrappers/persistence.py b/pogema/wrappers/persistence.py index 33df9aa..6658eef 100644 --- a/pogema/wrappers/persistence.py +++ b/pogema/wrappers/persistence.py @@ -40,8 +40,8 @@ def __init__(self, env, xy_offset=None): def step(self, action): result = self.env.step(action) self._step += 1 - for agent_idx in range(self.get_num_agents()): - agent_state = self._get_agent_state(self.grid, agent_idx) + for agent_idx in range(self.unwrapped.get_num_agents()): + agent_state = self._get_agent_state(self.unwrapped.grid, agent_idx) if agent_state != self._agent_states[agent_idx][-1]: self._agent_states[agent_idx].append(agent_state) @@ -51,19 +51,19 @@ def step_back(self): if self._step <= 0: return False self._step -= 1 - self.set_elapsed_steps(self._step) - for idx in reversed(range(self.get_num_agents())): + self.env.set_elapsed_steps(self._step) + for idx in reversed(range(self.unwrapped.get_num_agents())): if self._step < self._agent_states[idx][-1].step: self._agent_states[idx].pop() state = self._agent_states[idx][-1] if state.active: - self.grid.show_agent(idx) + self.unwrapped.grid.show_agent(idx) else: - self.grid.hide_agent(idx) - self.grid.move_agent_to_cell(idx, state.x, state.y) - self.grid.finishes_xy[idx] = state.tx, state.ty + self.unwrapped.grid.hide_agent(idx) + self.unwrapped.grid.move_agent_to_cell(idx, state.x, state.y) + self.unwrapped.grid.finishes_xy[idx] = state.tx, state.ty return True @@ -84,8 +84,8 @@ def reset(self, **kwargs): self._step = 0 self._agent_states = [] - for agent_idx in range(self.get_num_agents()): - self._agent_states.append([self._get_agent_state(self.grid, agent_idx)]) + for agent_idx in range(self.unwrapped.get_num_agents()): + self._agent_states.append([self._get_agent_state(self.unwrapped.grid, agent_idx)]) return result diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..64962e6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pogema" +description = "Partially Observable Grid Environment for Multiple Agents" +readme = "README.md" +requires-python = ">=3.10,<3.15" +license = {text = "MIT"} +authors = [{name = "Alexey Skrynnik", email = "skrynnikalexey@gmail.com"}] +dynamic = ["version"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Intended Audience :: Science/Research", +] +dependencies = [ + "gymnasium>=1.2.3", + "numpy>=2.0", + "pydantic>=2.12.5", + "pettingzoo==1.23.1" +] +urls = { "Homepage" = "https://github.com/Cognitive-AI-Systems/pogema" } + +[project.optional-dependencies] +test = ["pytest", "pytest-cov", "tabulate"] +dev = ["ruff"] + +[tool.hatch.version] +path = "pogema/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["pogema"] + +[tool.hatch.build.targets.sdist] +include = ["pogema"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 127 + +[tool.ruff.lint] +select = ["E", "F"] +ignore = [] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 07c5376..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -numpy>1.23.5,<=1.26.4 -pydantic>=1.8.2,<=1.9.1 -pytest>=6.2.5,<=7.1.2 -pettingzoo==1.23.1 -tabulate>=0.8.7,<=0.8.10 -gymnasium==0.28.1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 81c3327..0000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -import codecs -import os -import re - -from setuptools import setup, find_packages - -cur_dir = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(cur_dir, 'README.md'), 'rb') as f: - lines = [x.decode('utf-8') for x in f.readlines()] - lines = ''.join([re.sub('^<.*>\n$', '', x) for x in lines]) - long_description = lines - - -def read(*parts): - with codecs.open(os.path.join(cur_dir, *parts), 'r') as fp: - return fp.read() - - -def find_version(*file_paths): - version_file = read(*file_paths) - version_match = re.search( - r"^__version__ = ['\"]([^'\"]*)['\"]", - version_file, - re.M, - ) - if version_match: - return version_match.group(1) - - raise RuntimeError("Unable to find version string.") - - -setup( - name='pogema', - author='Alexey Skrynnik', - license='MIT', - version=find_version("pogema", "__init__.py"), - description='Partially Observable Grid Environment for Multiple Agents', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/Cognitive-AI-Systems/pogema', - install_requires=[ - "gymnasium==0.28.1", - "numpy>1.23.5,<=1.26.4", - "pydantic>=1.8.2,<=1.9.1", - ], - extras_require={ - - }, - package_dir={'': './'}, - packages=find_packages(where='./', include='pogema*'), - include_package_data=True, - python_requires=">=3.8,<3.13" -) diff --git a/tests/test_grid.py b/tests/test_grid.py index f8c5c8f..653112b 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -181,8 +181,8 @@ def test_custom_starts_and_finishes_random(): env = pogema_v0(grid_config=grid_config) env.reset() r = grid_config.obs_radius - assert [(x - r, y - r) for x, y in env.grid.positions_xy] == agents_xy and \ - [(x - r, y - r) for x, y in env.grid.finishes_xy] == targets_xy + assert [(x - r, y - r) for x, y in env.unwrapped.grid.positions_xy] == agents_xy and \ + [(x - r, y - r) for x, y in env.unwrapped.grid.finishes_xy] == targets_xy def test_out_of_bounds_for_custom_positions(): diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 146a05a..3e727ed 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -23,15 +23,15 @@ def test_sample_factory_integration(): env = pogema_v0(GridConfig(seed=7, num_agents=4, size=12, integration='SampleFactory')) env.reset() - assert env.num_agents == 4 - assert env.is_multiagent is True + assert env.unwrapped.get_num_agents() == 4 + assert env.env.is_multiagent is True # testing auto-reset wrapper for _ in range(2): dones = [False] infos = None while True: - _, _, terminated, truncated, infos = env.step(env.sample_actions()) + _, _, terminated, truncated, infos = env.step(env.unwrapped.sample_actions()) if all(terminated) or all(truncated): break diff --git a/tests/test_pogema_env.py b/tests/test_pogema_env.py index ad81170..02720e5 100644 --- a/tests/test_pogema_env.py +++ b/tests/test_pogema_env.py @@ -60,7 +60,7 @@ def run_episode(grid_config=None, env=None): results = [[obs, rewards, terminated, truncated, infos]] while True: - results.append(env.step(env.sample_actions())) + results.append(env.step(env.unwrapped.sample_actions())) terminated, truncated = results[-1][2], results[-1][3] if all(terminated) or all(truncated): break @@ -182,8 +182,8 @@ def test_custom_positions_and_num_agents(): gc.num_agents = num_agents env = pogema_v0(grid_config=gc) env.reset() - assert num_agents == len(env.get_agents_xy()) - assert num_agents == len(env.get_targets_xy()) + assert num_agents == len(env.unwrapped.get_agents_xy()) + assert num_agents == len(env.unwrapped.get_targets_xy()) def test_custom_positions_and_empty_num_agents(): @@ -198,7 +198,7 @@ def test_custom_positions_and_empty_num_agents(): ) env = pogema_v0(grid_config=gc) env.reset() - assert len(gc.agents_xy) == len(env.get_agents_xy()) + assert len(gc.agents_xy) == len(env.unwrapped.get_agents_xy()) def test_persistent_env(num_steps=100): @@ -217,7 +217,7 @@ def state_repr(observations, rewards, terminates, truncates, infos): return np.concatenate([np.array(observations).flatten(), terminates, truncates, np.array(rewards), ]) for current_step in range(num_steps): - actions = action_sampler.sample_actions(dim=env.get_num_agents()) + actions = action_sampler.sample_actions(dim=env.unwrapped.get_num_agents()) obs, reward, terminated, truncated, info = env.step(actions) first_run_observations.append(state_repr(obs, reward, terminated, truncated, info)) @@ -233,7 +233,7 @@ def state_repr(observations, rewards, terminates, truncates, infos): second_run_observations = [] for current_step in range(num_steps): - actions = action_sampler.sample_actions(dim=env.get_num_agents()) + actions = action_sampler.sample_actions(dim=env.unwrapped.get_num_agents()) obs, reward, terminated, truncated, info = env.step(actions) second_run_observations.append(state_repr(obs, reward, terminated, truncated, info)) assert np.isclose(first_run_observations[current_step], second_run_observations[current_step]).all() From 6fc985f616977e0b78236dafd6bdf3a82c585354 Mon Sep 17 00:00:00 2001 From: tviskaron Date: Mon, 23 Feb 2026 16:37:33 +0300 Subject: [PATCH 02/15] - migrated to newer version of checks in pydantic - fixed ruff checks --- pogema/a_star_policy.py | 2 +- pogema/generator.py | 3 +- pogema/grid_config.py | 175 ++++++++++++----------- pogema/svg_animation/animation_drawer.py | 3 +- pyproject.toml | 4 +- tests/test_grid.py | 14 +- tests/test_integrations.py | 8 +- tests/test_pogema_env.py | 4 +- 8 files changed, 109 insertions(+), 104 deletions(-) diff --git a/pogema/a_star_policy.py b/pogema/a_star_policy.py index f143601..0b41a96 100644 --- a/pogema/a_star_policy.py +++ b/pogema/a_star_policy.py @@ -99,7 +99,7 @@ def __init__(self, seed=0): self._rnd = np.random.default_rng(seed) def act(self, obs): - xy, target_xy, obstacles, agents = obs['xy'], obs['target_xy'], obs['obstacles'], obs['agents'] + xy, target_xy, obstacles, _ = obs['xy'], obs['target_xy'], obs['obstacles'], obs['agents'] if self._saved_xy is not None and h(self._saved_xy, xy) > 1: diff --git a/pogema/generator.py b/pogema/generator.py index cb0e3f4..c45b21d 100644 --- a/pogema/generator.py +++ b/pogema/generator.py @@ -104,7 +104,8 @@ def placing(order, components, grid, start_id, num_agents): return positions_xy, finishes_xy def generate_from_possible_positions(grid_config: GridConfig): - if len(grid_config.possible_agents_xy) < grid_config.num_agents or len(grid_config.possible_targets_xy) < grid_config.num_agents: + if (len(grid_config.possible_agents_xy) < grid_config.num_agents or + len(grid_config.possible_targets_xy) < grid_config.num_agents): raise OverflowError(f"Can't create task. Not enough possible positions for {grid_config.num_agents} agents.") rng = np.random.default_rng(grid_config.seed) rng.shuffle(grid_config.possible_agents_xy) diff --git a/pogema/grid_config.py b/pogema/grid_config.py index ff18c23..c3c055b 100644 --- a/pogema/grid_config.py +++ b/pogema/grid_config.py @@ -1,15 +1,6 @@ import sys from typing import Optional, Union -from pydantic import validator, root_validator -from pydantic import BaseModel, model_validator - -from pogema.utils import CommonSettings - -from typing_extensions import Literal - -import sys -from typing import Optional, Union -from pydantic import validator, model_validator +from pydantic import field_validator, model_validator from pogema.utils import CommonSettings from typing_extensions import Literal @@ -36,12 +27,62 @@ class GridConfig(CommonSettings): max_episode_steps: int = 64 auto_reset: Optional[bool] = None + @model_validator(mode='before') + @classmethod + def process_map_and_defaults(cls, data): + if isinstance(data, dict): + # Process string map into list and extract agents/targets + map_val = data.get('map') + if map_val is not None and isinstance(map_val, str): + free = CommonSettings().FREE + obstacle = CommonSettings().OBSTACLE + map_val, agents_xy, targets_xy, possible_agents_xy, possible_targets_xy = cls.str_map_to_list( + map_val, free, obstacle + ) + if agents_xy and targets_xy and data.get('agents_xy') is not None and data.get( + 'targets_xy') is not None: + raise KeyError("""Can't create task. Please provide agents_xy and targets_xy only once. + Either with parameters or with a map.""") + if (agents_xy or targets_xy) and (possible_agents_xy or possible_targets_xy): + raise KeyError("""Can't create task. Mark either possible locations or precise ones.""") + elif agents_xy and targets_xy: + data['agents_xy'] = agents_xy + data['targets_xy'] = targets_xy + data['num_agents'] = len(agents_xy) + elif (data.get('agents_xy') is None or data.get( + 'targets_xy') is None) and possible_agents_xy and possible_targets_xy: + data['possible_agents_xy'] = possible_agents_xy + data['possible_targets_xy'] = possible_targets_xy + + data['map'] = map_val + + # Compute map-derived dimensions + if map_val is not None and not isinstance(map_val, str): + height = len(map_val) + width = 0 + area = 0 + for line in map_val: + width = max(width, len(line)) + area += len(line) + data['size'] = max(width, height) + data['width'] = width + data['height'] = height + data['density'] = sum([sum(line) for line in map_val]) / area + + # Default num_agents + if data.get('num_agents') is None: + if data.get('agents_xy'): + data['num_agents'] = len(data['agents_xy']) + else: + data['num_agents'] = 1 + + return data + @model_validator(mode='after') - def validate_dimensions_and_positions(cls, model): - # Use getattr for safe access, with default fallback - width = getattr(model, 'width', None) - height = getattr(model, 'height', None) - size = getattr(model, 'size', 8) + def validate_dimensions_and_positions(self): + width = self.width + height = self.height + size = self.size width_provided = width is not None and width > 0 height_provided = height is not None and height > 0 @@ -63,9 +104,9 @@ def validate_dimensions_and_positions(cls, model): size = max(width, height, 2) - setattr(model, 'width', width) - setattr(model, 'height', height) - setattr(model, 'size', size) + self.width = width + self.height = height + self.size = size if not (1 <= width <= 4096): raise ValueError(f"width must be in [1, 4096], got {width}") @@ -75,23 +116,24 @@ def validate_dimensions_and_positions(cls, model): raise ValueError(f"size must be in [2, 4096], got {size}") # Validate positions - agents_xy = getattr(model, 'agents_xy', None) - targets_xy = getattr(model, 'targets_xy', None) + agents_xy = self.agents_xy + targets_xy = self.targets_xy if agents_xy is not None: - cls.check_positions(agents_xy, width, height) + self.check_positions(agents_xy, width, height) if targets_xy is not None: first_element = targets_xy[0] if isinstance(first_element[0], (list, tuple)): for agent_goals in targets_xy: - cls.check_positions(agent_goals, width, height) + self.check_positions(agent_goals, width, height) else: - cls.check_positions(targets_xy, width, height) + self.check_positions(targets_xy, width, height) - return model + return self - @validator('seed') + @field_validator('seed') + @classmethod def seed_initialization(cls, v): assert v is None or (0 <= v < sys.maxsize), f"seed must be in [0, {sys.maxsize}]" return v @@ -105,24 +147,29 @@ def _validate_dimension(v, field_name): assert 1 <= v <= 4096, f"{field_name} must be in [1, 4096]" return v - @validator('size') + @field_validator('size') + @classmethod def size_restrictions(cls, v): return cls._validate_dimension(v, 'size') - @validator('width') + @field_validator('width') + @classmethod def width_restrictions(cls, v): return cls._validate_dimension(v, 'width') - @validator('height') + @field_validator('height') + @classmethod def height_restrictions(cls, v): return cls._validate_dimension(v, 'height') - @validator('density') + @field_validator('density') + @classmethod def density_restrictions(cls, v): assert 0.0 <= v <= 1, "density must be in [0, 1]" return v - @validator('agents_xy') + @field_validator('agents_xy') + @classmethod def agents_xy_validation(cls, v): if v is not None: if not isinstance(v, (list, tuple)): @@ -134,8 +181,9 @@ def agents_xy_validation(cls, v): raise ValueError("Position coordinates must be integers") return v - @validator('targets_xy') - def targets_xy_validation(cls, v, values): + @field_validator('targets_xy') + @classmethod + def targets_xy_validation(cls, v, info): if v is not None: if not v or not isinstance(v, (list, tuple)): raise ValueError("targets_xy must be a list") @@ -154,7 +202,7 @@ def targets_xy_validation(cls, v, values): if not all(isinstance(coord, int) for coord in position): raise ValueError("Position coordinates must be integers") else: - on_target = values.get('on_target', 'finish') + on_target = info.data.get('on_target', 'finish') if on_target == 'restart': raise ValueError( "on_target='restart' requires goal sequences, not single goals. " @@ -178,66 +226,27 @@ def check_positions(v, width, height): if not (0 <= x < height and 0 <= y < width): raise IndexError(f"Position is out of bounds! {position} is not in [{0}, {height}] x [{0}, {width}]") - - @validator('num_agents', always=True) - def num_agents_must_be_positive(cls, v, values): - if v is None: - if values['agents_xy']: - v = len(values['agents_xy']) - else: - v = 1 + @field_validator('num_agents') + @classmethod + def num_agents_must_be_positive(cls, v): assert 1 <= v <= 10000000, "num_agents must be in [1, 10000000]" return v - @validator('obs_radius') + @field_validator('obs_radius') + @classmethod def obs_radius_must_be_positive(cls, v): assert 1 <= v <= 128, "obs_radius must be in [1, 128]" return v - @validator('map', always=True) - def map_validation(cls, v, values): + @field_validator('map') + @classmethod + def map_validation(cls, v): if v is None: return None - if isinstance(v, str): - v, agents_xy, targets_xy, possible_agents_xy, possible_targets_xy = cls.str_map_to_list(v, values['FREE'], - values['OBSTACLE']) - if agents_xy and targets_xy and values.get('agents_xy') is not None and values.get( - 'targets_xy') is not None: - raise KeyError("""Can't create task. Please provide agents_xy and targets_xy only once. - Either with parameters or with a map.""") - if (agents_xy or targets_xy) and (possible_agents_xy or possible_targets_xy): - raise KeyError("""Can't create task. Mark either possible locations or precise ones.""") - elif agents_xy and targets_xy: - values['agents_xy'] = agents_xy - values['targets_xy'] = targets_xy - values['num_agents'] = len(agents_xy) - elif (values.get('agents_xy') is None or values.get( - 'targets_xy') is None) and possible_agents_xy and possible_targets_xy: - values['possible_agents_xy'] = possible_agents_xy - values['possible_targets_xy'] = possible_targets_xy - - height = len(v) - width = 0 - area = 0 - for line in v: - width = max(width, len(line)) - area += len(line) - - values['size'] = max(width, height) - values['width'] = width - values['height'] = height - values['density'] = sum([sum(line) for line in v]) / area - + # String maps are already processed in model_validator(mode='before') + # At this point v should be a list return v - # @validator('possible_agents_xy') - # def possible_agents_xy_validation(cls, v): - # return v - # - # @validator('possible_targets_xy') - # def possible_targets_xy_validation(cls, v): - # return v - @staticmethod def str_map_to_list(str_map, free, obstacle): obstacles = [] @@ -292,7 +301,7 @@ def str_map_to_list(str_map, free, obstacle): return obstacles, agents_xy, targets_xy, possible_agents_xy, possible_targets_xy def update_config(self, **kwargs): - current_values = self.dict() + current_values = self.model_dump() if 'size' in kwargs: current_values.pop('width', None) diff --git a/pogema/svg_animation/animation_drawer.py b/pogema/svg_animation/animation_drawer.py index 5ccf37d..ff19ae1 100644 --- a/pogema/svg_animation/animation_drawer.py +++ b/pogema/svg_animation/animation_drawer.py @@ -84,7 +84,8 @@ def render(self): width="{scaled_width}" height="{scaled_height}" viewBox="{" ".join(map(str, view_box))}">''' definitions = f''' - + + + +
+
+
+

Map Size

+
+
+ + + + + + + + + +
+
+ +
+
+

Quick Sizes

+
+
+ + + + +
+
+ +
+
+

Obstacles

+
+
+ + +
+
+ + + +
+
+
+ Size + 16×16 +
+
+ Obstacles + 0 +
+
+ Free Cells + 256 +
+
+ Obstacle % + 0.0% +
+
+
+ +
+
+ Workflow
+ Set map size, randomize or clear obstacles, edit them, generate agents and targets, then copy the Pogema snippet. +
+
+ +
+ +
+ +
+
+

Pogema Editor

+ +
+
+
+ Agents + 0 +
+
+ + + + +
+
+
+
+ Controls
+ Tab / Shift+Tab — cycle agents
+ Space — toggle agent / target
+ 0-9 — select agent by ID
+ + / - — add / remove agent
+ Arrows / HJKL — move
+ Drag agents or targets with the mouse +
+
+

Generate agents

+
+ + + +
+ +
+ +
+ + +
+
+ + + + + + + + + + + + + + From 4fa8e631d16c364fbbc5d3959cc451023860be46 Mon Sep 17 00:00:00 2001 From: Alexey Skrynnik Date: Sat, 11 Apr 2026 13:54:00 +0300 Subject: [PATCH 15/15] Update __init__.py --- pogema/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pogema/__init__.py b/pogema/__init__.py index 9488aaf..ac26ddb 100644 --- a/pogema/__init__.py +++ b/pogema/__init__.py @@ -22,7 +22,7 @@ from pogema.wrappers.multi_time_limit import MultiTimeLimit from pogema.wrappers.persistence import PersistentWrapper -__version__ = '1.4.0' +__version__ = '2.0.0a' __all__ = [ 'GridConfig',