返回代码索引

delivcoal_env.py

空间网格、legacy20/full27、路径意图适配

原文件 · 421 行 · 18828 字节

third_party/marl_mrt2a/marl_mrt2a/marl/src/envs/delivcoal_env.py
SHA-256: a2c36aac21fe51d816ba164e49bce8d0ea5a4a3493e57f1d6996cca0b9a72a6c

1"""让 PyMARL 的 MARL-MRT2A 策略可以在 DelivCoal 中运行。"""23from __future__ import annotations45import importlib.util6import sys7from pathlib import Path8from types import SimpleNamespace910import numpy as np11import yaml1213from .multiagentenv import MultiAgentEnv141516REPO_ROOT = Path(__file__).resolve().parents[6]17DEFAULT_CONFIG = REPO_ROOT / "experiments/reproduction/delivcoal_formal_base.yaml"181920def _load_discrete_env():21    """用单独的模块名加载 DelivCoal,避免和第三方代码中的 env 包重名。"""22    package_name = "gat_ppo_delivcoal"23    if package_name not in sys.modules:24        package_dir = REPO_ROOT / "envs/delivcoal"25        spec = importlib.util.spec_from_file_location(26            package_name,27            package_dir / "__init__.py",28            submodule_search_locations=[str(package_dir)],29        )30        module = importlib.util.module_from_spec(spec)31        sys.modules[package_name] = module32        spec.loader.exec_module(module)33    module_name = f"{package_name}.env_discrete"34    if module_name not in sys.modules:35        spec = importlib.util.spec_from_file_location(36            module_name, REPO_ROOT / "envs/delivcoal/env_discrete.py"37        )38        module = importlib.util.module_from_spec(spec)39        sys.modules[module_name] = module40        spec.loader.exec_module(module)41    return sys.modules[module_name].DiscreteActionEnv424344def _load_config(path):45    path = Path(path).expanduser().resolve()46    payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}47    rewards = Path(payload.get("rewards_config_file", "rewards.yaml"))48    if not rewards.is_absolute():49        rewards = REPO_ROOT / rewards50    payload["rewards_config_file"] = str(rewards)51    return SimpleNamespace(**payload)525354class DelivCoalWrapper(MultiAgentEnv):55    """把 DelivCoal 的任务选择转换成 MARL-MRT2A 使用的二维动作网格。5657    每个可选任务占用一个网格位置。网络选择位置后,再转换回 DelivCoal 任务编号。58    """5960    def __init__(self, key="delivcoal-formal", config_path=str(DEFAULT_CONFIG),61                 grid_size=21, seed=0, time_limit=None,62                 observation_layout="legacy20", share_intention="none", **kwargs):63        del kwargs, key64        if int(grid_size) % 2 != 1:65            raise ValueError("grid_size must be odd for MARL-MRT2A target mapping")66        self.grid_size = int(grid_size)67        self.half = self.grid_size // 268        self._seed = int(seed)69        self.observation_layout = str(observation_layout)70        if self.observation_layout not in ("legacy20", "full27"):71            raise ValueError("observation_layout must be legacy20 or full27")72        self.share_intention = str(share_intention)73        if self.share_intention not in ("none", "path"):74            raise ValueError("share_intention must be none or path")75        if self.share_intention == "path" and self.observation_layout != "full27":76            raise ValueError("path intention requires observation_layout=full27")77        self._test_mode = False78        self._episode = 079        self._config = _load_config(config_path)80        self.episode_limit = int(time_limit or self._config.max_steps)81        self._config.max_steps = self.episode_limit82        self._config.episode_length = self.episode_limit83        self._config.episode_time_limit = self.episode_limit84        env_class = _load_discrete_env()85        self._env = env_class(self._config)86        self._env.seed(self._seed)87        self.n_agents = self._env.num_agent88        self.n_actions = self.grid_size * self.grid_size89        # legacy20 使用 20 个通道;full27 另外保存完整任务字段和路径信息。90        self.n_channels = 20 if self.observation_layout == "legacy20" else 2791        self._obs = None92        self._state = None93        self._active = None94        self._available_tasks = None95        self._cell_to_task = None96        self._task_to_cell = None97        self._latest_info = {}98        self._sent_total = 0.099        self._return_total = 0.0100101    def _nearest_free(self, desired, occupied):102        """目标格已占用时,从近到远寻找一个空格。"""103        x0, y0 = desired104        if desired not in occupied:105            return desired106        for radius in range(1, self.grid_size):107            candidates = []108            for dx in range(-radius, radius + 1):109                for dy in range(-radius, radius + 1):110                    if max(abs(dx), abs(dy)) != radius:111                        continue112                    x = int(np.clip(x0 + dx, 0, self.grid_size - 1))113                    y = int(np.clip(y0 + dy, 0, self.grid_size - 1))114                    candidates.append((abs(dx) + abs(dy), x, y))115            for _, x, y in sorted(set(candidates)):116                if (x, y) not in occupied:117                    return (x, y)118        raise RuntimeError("spatial action grid has no free cell")119120    def _desired_cell(self, agent_id, task_id, task_row):121        task_pos = np.asarray(task_row[:2], dtype=np.float32)122        agent_pos = np.asarray(self._obs_dict["ego"][agent_id, :2])123        relative = np.clip(task_pos - agent_pos, -1.0, 1.0)124        desired = np.rint(relative * self.half).astype(np.int64) + self.half125        # Idle 没有位置,固定放在网格中心。126        if task_id == self._env.env.idle_task_id:127            desired[:] = self.half128        return tuple(int(value) for value in desired)129130    def _build_mapping(self):131        """为每个智能体建立任务编号和网格编号的双向映射。"""132        self._cell_to_task = []133        self._task_to_cell = []134        for agent_id in range(self.n_agents):135            cell_to_task = {}136            task_to_cell = {}137            available_ids = np.flatnonzero(self._available_tasks[agent_id] > 0.5)138            # 先放配送和探索任务,再给 Idle、Resupply 分配剩余位置。139            special = {self._env.env.idle_task_id, self._env.env.resupply_task_id}140            ordered = sorted(available_ids, key=lambda task: (task in special, int(task)))141            for task_id in ordered:142                desired = self._desired_cell(143                    agent_id, int(task_id), self._obs_dict["tasks"][agent_id, task_id]144                )145                cell = self._nearest_free(desired, cell_to_task)146                flat = int(np.ravel_multi_index(cell, (self.grid_size, self.grid_size)))147                # cell_to_task 用于执行动作,task_to_cell 用于生成观测和动作 mask。148                cell_to_task[flat] = int(task_id)149                task_to_cell[int(task_id)] = cell150            self._cell_to_task.append(cell_to_task)151            self._task_to_cell.append(task_to_cell)152153    def _task_components(self, task):154        """从 compact 或 full 任务观测中取出需求、货量、期限和探索信息。"""155        cap_dim = int(self._config.cap_dim)156        mode = getattr(self._config, "coalition_task_feature_mode", "compact")157        requirements = np.zeros(4, dtype=np.float32)158        if mode == "full":159            values = np.asarray(task[2:2 + cap_dim], dtype=np.float32)160            requirements[:min(4, cap_dim)] = values[:4]161            demand = float(task[2 + cap_dim])162            deadline = float(task[3 + cap_dim])163            exploration = np.asarray(task[4 + cap_dim:7 + cap_dim])164        else:165            demand = float(task[2])166            deadline = float(task[3])167            requirements[0] = float(task[4])168            exploration = np.asarray(task[2:5])169        return requirements, demand, deadline, exploration170171    def _rasterize_path_intentions(self, focal_agent_id, grid):172        """把其他智能体的路径画到第 26 个观测通道。"""173        if self.share_intention != "path":174            return175        focal = self._env.env.agents[focal_agent_id]176        focal_pos = np.asarray(focal.position, dtype=np.float32) / float(177            self._config.map_size178        )179        for other_id, agent in enumerate(self._env.env.agents):180            if other_id == focal_agent_id or not agent.living or not agent.path:181                continue182            points = np.asarray(agent.path, dtype=np.float32)183            if points.ndim != 2 or points.shape[1] != 2:184                continue185            points = points / float(self._config.map_size)186            sampled = []187            for start, end in zip(points[:-1], points[1:]):188                cell_span = np.max(np.abs(end - start)) * self.half189                count = max(2, int(np.ceil(cell_span)) + 1)190                sampled.extend(np.linspace(start, end, count, endpoint=False))191            sampled.append(points[-1])192            total = max(1, len(sampled) - 1)193            for index, point in enumerate(sampled):194                relative = np.clip(point - focal_pos, -1.0, 1.0)195                cell = np.rint(relative * self.half).astype(np.int64) + self.half196                x, y = int(cell[0]), int(cell[1])197                value = 1.0 - 0.5 * index / total198                grid[26, x, y] = max(grid[26, x, y], value)199200    def _agent_grid(self, agent_id):201        """把一个智能体看到的任务和队友写入以自身为中心的栅格。"""202        grid = np.zeros(203            (self.n_channels, self.grid_size, self.grid_size), dtype=np.float32204        )205        grid[0, self.half, self.half] = 1.0206        delivery_end = self._env.env.delivery_task_num207        explore_start = self._env.env.explore_task_start208        explore_end = self._env.env.explore_task_end209        idle_id = self._env.env.idle_task_id210        for task_id, cell in self._task_to_cell[agent_id].items():211            # 任务类型和任务数值字段写入同一个网格位置的不同通道。212            x, y = cell213            if task_id < delivery_end:214                grid[1, x, y] = 1.0215            elif explore_start <= task_id < explore_end:216                grid[2, x, y] = 1.0217            else:218                grid[3, x, y] = 1.0 if task_id == idle_id else 0.5219            task = self._obs_dict["tasks"][agent_id, task_id]220            if self.observation_layout == "legacy20":221                grid[4:7, x, y] = task[2:5]222            else:223                requirements, demand, deadline, exploration = (224                    self._task_components(task)225                )226                grid[4:8, x, y] = requirements227                grid[8, x, y] = demand228                grid[9, x, y] = deadline229                grid[10:13, x, y] = exploration230        ego_pos = self._obs_dict["ego"][agent_id, :2]231        # 其他智能体按相对位置写入队友通道,重合位置累计人数并保留最大属性。232        for other_id, other in enumerate(self._obs_dict["others"][agent_id]):233            if other_id == agent_id or other[-1] <= 0.5:234                continue235            relative = np.clip(other[:2] - ego_pos, -1.0, 1.0)236            cell = np.rint(relative * self.half).astype(np.int64) + self.half237            x, y = (int(cell[0]), int(cell[1]))238            if self.observation_layout == "legacy20":239                grid[7, x, y] = min(1.0, grid[7, x, y] + 1.0 / self.n_agents)240                grid[8, x, y] = max(grid[8, x, y], float(other[6]))241                grid[9, x, y] = max(grid[9, x, y], float(other[7]))242                grid[16:20, x, y] = np.maximum(243                    grid[16:20, x, y], other[2:6]244                )245            else:246                grid[13, x, y] = min(247                    1.0, grid[13, x, y] + 1.0 / self.n_agents248                )249                grid[14, x, y] = max(grid[14, x, y], float(other[6]))250                grid[15, x, y] = max(grid[15, x, y], float(other[7]))251                grid[22:26, x, y] = np.maximum(252                    grid[22:26, x, y], other[2:6]253                )254        coalition = int(self._env.env.agents[agent_id].coalition)255        # current_target 通道标出当前正在执行的任务位置。256        current = self._task_to_cell[agent_id].get(coalition)257        if current is not None:258            target_channel = 10 if self.observation_layout == "legacy20" else 16259            grid[target_channel, current[0], current[1]] = 1.0260        if self.observation_layout == "legacy20":261            # 与当前智能体自身有关的字段只写在中心格。262            grid[11, self.half, self.half] = float(self._active[agent_id, 0])263            grid[12:16, self.half, self.half] = self._obs_dict["ego"][agent_id, 2:6]264        else:265            grid[17, self.half, self.half] = float(self._active[agent_id, 0])266            grid[18:22, self.half, self.half] = self._obs_dict["ego"][agent_id, 2:6]267            self._rasterize_path_intentions(agent_id, grid)268        return grid269270    def _refresh(self, obs, active, available):271        """环境推进后重新生成任务到网格的映射、局部观测和全局状态。"""272        self._obs_dict = obs273        self._active = np.asarray(active, dtype=np.float32)274        self._available_tasks = np.asarray(available, dtype=np.float32)275        self._build_mapping()276        self._obs = [self._agent_grid(agent_id) for agent_id in range(self.n_agents)]277        self._state = np.mean(np.stack(self._obs), axis=0).astype(np.float32)278279    def _fallback_task(self, agent_id):280        """动作无效或智能体未激活时,选择可继续执行的任务。"""281        coalition = int(self._env.env.agents[agent_id].coalition)282        if coalition in self._task_to_cell[agent_id]:283            return coalition284        idle = int(self._env.env.idle_task_id)285        if self._available_tasks[agent_id, idle] > 0.5:286            return idle287        return int(np.flatnonzero(self._available_tasks[agent_id] > 0.5)[0])288289    def step(self, actions):290        # 将网格动作转换成任务编号。未激活智能体继续当前任务或选择 Idle。291        decision_mask = self._active.astype(np.uint8, copy=True)292        task_actions = []293        for agent_id, raw_action in enumerate(actions):294            flat = int(raw_action)295            task_id = self._cell_to_task[agent_id].get(flat)296            if task_id is None or self._active[agent_id, 0] <= 0.5:297                # 网格位置没有任务,或智能体本步无需决策时,使用安全的继续动作。298                task_id = self._fallback_task(agent_id)299            task_actions.append(task_id)300        obs, rewards, dones, info, active, bad_mask, available = self._env.step(301            task_actions302        )303        self._sent_total += float(info.get("sent", 0.0))304        team_reward = float(np.sum(rewards))305        # PyMARL 接收团队总奖励,环境的逐智能体奖励在这里求和。306        self._return_total += team_reward307        self._refresh(obs, active, available)308        terminated = bool(np.all(dones))309        delivery = [task for task in info["tasks"] if int(task["type"]) == 1]310        completed = sum(bool(task["completed"]) for task in delivery)311        sent_goal = max(1e-8, float(info.get("sent_goal", 0.0)))312        episode_limit = bool(terminated and np.any(np.asarray(bad_mask) < 0.5))313        public_info = {314            # runner 依赖这些字段记录目标更新、episode 结束原因和任务指标。315            "robot_info": self._robot_info(),316            "action_exec": decision_mask,317            "episode_limit": episode_limit,318            "success": float(bool(delivery) and completed == len(delivery)),319            "task_completion_ratio": float(completed / max(1, len(delivery))),320            "sent_percentage": float(self._sent_total / sent_goal),321            "coalition_changes": float(322                info.get("reconfiguration", {}).get("total_coalition_changes", 0)323            ),324        }325        self._latest_info = public_info326        return team_reward, terminated, public_info327328    def _robot_info(self):329        """返回每个智能体在网格中心以及当前目标所在的格子。"""330        result = []331        center = (self.half, self.half)332        for agent_id, agent in enumerate(self._env.env.agents):333            cell = self._task_to_cell[agent_id].get(int(agent.coalition))334            result.append((center, cell))335        return result336337    def reset(self):338        # episode 编号传给 DelivCoal,用于环境重置模式和课程切换。339        obs, active, available = self._env.reset(self._episode)340        self._episode += 1341        self._sent_total = 0.0342        self._return_total = 0.0343        self._latest_info = {}344        self._refresh(obs, active, available)345        return self.get_obs(), self.get_state()346347    def get_obs(self):348        return self._obs349350    def get_obs_agent(self, agent_id):351        return self._obs[agent_id]352353    def get_obs_size(self):354        return (self.n_channels, self.grid_size, self.grid_size)355356    def get_state(self):357        return self._state358359    def get_state_size(self):360        return (self.n_channels, self.grid_size, self.grid_size)361362    def get_avail_actions(self):363        # PyMARL 需要网格长度的 mask,这里只开放已经分配给任务的格子。364        result = []365        for agent_id in range(self.n_agents):366            mask = np.zeros(self.n_actions, dtype=np.int32)367            if self._active[agent_id, 0] > 0.5:368                mask[list(self._cell_to_task[agent_id])] = 1369            else:370                task_id = self._fallback_task(agent_id)371                cell = self._task_to_cell[agent_id][task_id]372                mask[np.ravel_multi_index(cell, (self.grid_size, self.grid_size))] = 1373            result.append(mask)374        return result375376    def get_avail_agent_actions(self, agent_id):377        return self.get_avail_actions()[agent_id]378379    def get_total_actions(self):380        return self.n_actions381382    def set_mode(self, test_mode):383        self._test_mode = bool(test_mode)384        return True385386    def seed(self):387        return self._seed388389    def close(self):390        self._env.close()391392    def render(self):393        return None394395    def save_replay(self):396        return None397398    def get_stats(self):399        return {}400401    def get_channel_info(self):402        if self.observation_layout == "full27":403            return {404                "task_type": [1, 2, 3],405                "task_requirements": [4, 5, 6, 7],406                "task_demand": 8,407                "task_deadline": 9,408                "exploration_geometry": [10, 11, 12],409                "team": [13, 14, 15],410                "current_target": 16,411                "active": 17,412                "ego_capabilities": [18, 19, 20, 21],413                "team_capabilities": [22, 23, 24, 25],414                "path_intention": 26,415            }416        return {417            "task_type": [1, 2, 3],418            "task_attributes": [4, 5, 6],419            "team": [7, 8, 9],420            "current_target": 10,421        }