返回代码索引

env_core.py

MACF 基础环境及历史调用参照

原文件 · 1738 行 · 78942 字节

envs/macf/env_core.py
SHA-256: 743378fb23dca44fa5159215b4dc33c99aa238d8638f7d926a19dae254f67581

1import copy2import numpy as np3import pickle4import os5from collections import deque6import yaml78eps = 1e-6910from enum import IntEnum1112class AgentType(IntEnum):13    Drone = 014    Vehicle = 11516class TaskType(IntEnum):17    Explore = 018    Deliver = 119    Idle = 220    Resupply = 32122def dist_point_segment(p, a, b):23    # 点到线段的最短距离;用于判断路径线段是否穿过感知范围或危险圆。24    p = np.array(p, dtype=float)25    a = np.array(a, dtype=float)26    b = np.array(b, dtype=float)27    ab = b - a28    if np.allclose(ab, 0):29        return np.linalg.norm(p - a)30    t = np.dot(p - a, ab) / np.dot(ab, ab)31    t = np.clip(t, 0.0, 1.0)32    proj = a + t * ab33    return np.linalg.norm(p - proj)3435def fast_path(start, goal, danger_points=None, danger_radius=1.0, clearance=0.5, max_iter=8):36    '''连续空间近似路径规划器。3738    先尝试直线到达目标;如果路径线段穿过危险圆,则在障碍物附近插入垂直偏移路点绕行。39    返回值包含起点和终点的路点列表。40    '''41    if danger_points is None:42        danger_points = []4344    def segment_intersects_circle(a, b, c, r):45        return dist_point_segment(c, a, b) <= r4647    s = tuple(map(float, start))48    g = tuple(map(float, goal))49    if np.allclose(s, g):50        return [s]5152    waypoints = [s, g]53    iter_count = 054    # 逐段检查路径;某段碰到危险圆时,就在该段中插入一个绕行点。55    while iter_count < max_iter:56        changed = False57        new_waypoints = [waypoints[0]]58        for i in range(len(waypoints)-1):59            a = waypoints[i]60            b = waypoints[i+1]61            # 只处理第一个相交障碍,插入路点后下一轮再继续检查。62            hit = None63            for c in danger_points:64                if segment_intersects_circle(a, b, c, danger_radius + clearance):65                    hit = c66                    break67            if hit is None:68                new_waypoints.append(b)69                continue70            # 绕行点取“障碍物到线段最近点”的垂直方向偏移。71            a_np = np.array(a); b_np = np.array(b); c_np = np.array(hit)72            ab = b_np - a_np73            if np.allclose(ab, 0):74                # 线段退化时没有稳定垂线方向,直接使用一个固定侧向偏移。75                dir_perp = np.array([1.0, 0.0])76            else:77                t = np.dot(c_np - a_np, ab) / np.dot(ab, ab)78                t = np.clip(t, 0.0, 1.0)79                closest = a_np + t * ab80                # 正常线段使用垂直方向生成绕行候选点。81                dir_perp = np.array([-ab[1], ab[0]])82                if np.allclose(dir_perp, 0):83                    dir_perp = np.array([1.0, 0.0])84                dir_perp = dir_perp / np.linalg.norm(dir_perp)85            # 通过比较两侧候选点离其他危险圆的距离,选择更安全的一侧。86            # 左右两侧各给一个候选绕行点,选择碰撞风险较小的一侧。87            det_dist = danger_radius + clearance88            cand1 = tuple((closest + dir_perp * det_dist).tolist()) if 'closest' in locals() else tuple((a_np + dir_perp * det_dist).tolist())89            cand2 = tuple((closest - dir_perp * det_dist).tolist()) if 'closest' in locals() else tuple((a_np - dir_perp * det_dist).tolist())90            # 分数越小表示越不容易贴近其他危险圆。91            def collision_score(pt):92                ssum = 0.093                for c2 in danger_points:94                    d = np.linalg.norm(np.array(pt) - np.array(c2)) - danger_radius95                    ssum += max(0.0, (clearance - d))96                return ssum9798            s1 = collision_score(cand1)99            s2 = collision_score(cand2)100            detour = cand1 if s1 <= s2 else cand2101            # 在当前线段终点前插入绕行点。102            new_waypoints.append(detour)103            new_waypoints.append(b)104            changed = True105        waypoints = new_waypoints106        iter_count += 1107        if not changed:108            break109110    # 去掉几乎重合的路点,避免移动时产生零长度线段。111    out = [tuple(map(float, waypoints[0]))]112    for p in waypoints[1:]:113        if np.linalg.norm(np.array(p) - np.array(out[-1])) > eps:114            out.append(tuple(map(float, p)))115    return out116117class AgentLastState:118    """保存上一步智能体状态,奖励函数用它计算切换、距离变化和资源变化。"""119120    __slots__ = ['coalition', 'new_task_found', 'current_task_completed', 'any_task_completed', 'coalition_completed', 'dist', 'load', 'idle']121    122    def __init__(self):123        self.coalition = EnvCore.idle_task_id124        self.new_task_found = False125        self.current_task_completed = False126        self.any_task_completed = False127        self.coalition_completed = False128        self.dist = 0.0129        self.load = 0.0130        self.idle = True131132class Agent:133    """环境内部的智能体实体。134135    capbilities 四维分别驱动感知、移动、载荷和电量上限;每步事件字段在 step 末尾清零。136    """137138    __slots__ = ['aid', 'position', 'capbilities', 'perception', 'mobility', 'load',139                 'battery', 'type', 'living', 'idle', 'coalition', 'path', 'last_state',140                 'dead', 'new_task_found', 'coalition_changed', 'discovery', 'delivery', 'coalition_delivery', 'resupply_ratio', 'init_load', 'resupplied',141                 'current_task_completed', 'any_task_completed', 'coalition_completed', 'dist', 'unchanged_time']142143    def __init__(self, aid: int, pos: np.ndarray, capbilities: np.ndarray, rng=None):144        self.aid = aid145        self.position = np.array(pos, dtype=float)146        self.capbilities = capbilities147        self.perception = float(capbilities[0] * 50.0)148        self.mobility = float(capbilities[1] * 10) + 10149        self.init_load = int(capbilities[2] * 70) + 30150        self.load = self.init_load151        self.battery = int(capbilities[3] * 500) + 500152        self.type = rng.choice([AgentType.Drone, AgentType.Vehicle]) if rng is not None else np.random.choice([AgentType.Drone, AgentType.Vehicle])153        self.living = True154        self.idle = True155        self.coalition = EnvCore.idle_task_id156        self.path = []157158        self.dead = False159        self.new_task_found = False160        self.current_task_completed = False161        self.any_task_completed = False162        self.coalition_completed = False163        self.coalition_changed = False164        self.discovery = 0.0165        self.delivery = 0.0166        self.coalition_delivery = 0.0167        self.dist = 0.0168        self.unchanged_time = 0.0169170        self.resupply_ratio = 0.0171        self.resupplied = False172173        self.last_state = AgentLastState()174175    def act(self, cid: int):176        # 只有联盟编号变化时才清空路径;保持同一联盟会累积 unchanged_time。177        if cid != self.coalition:178            self.coalition = cid179            self.coalition_changed = True180            self.path = []181            self.unchanged_time = 0.0182        else:183            self.unchanged_time += 1.0184185        if cid == EnvCore.idle_task_id:186            self.idle = True187188    def consume(self):189        # 当前模型按步扣电,电量耗尽后变为死亡,死亡事件只在本步奖励中使用。190        if self.living:191            self.battery -= 1192            if self.battery == 0:193                self.living = False194                self.dead = True195196    def deliver(self, demand):197        # 交付量受当前载荷和任务剩余需求共同限制;无货交付会给负事件信号。198        delivery = np.min([self.load, demand])199        self.load -= delivery200        self.delivery = delivery if delivery > 0 else -10201        self.idle = True202203        if self.delivery < 0:204            pass205            206        return delivery207208    def complete(self):209        # 当前智能体完成自己负责的当前动作,runner 会据此决定是否再次激活该 agent。210        self.current_task_completed = True211        self.idle = True212213    def resupply(self):214        # 补给恢复电量和载荷,并记录本次补给比例用于奖励。215        self.living = True216        self.dead = False217        self.idle = True218        self.battery = int(self.capbilities[3] * 500) + 500219        self.resupply_ratio = (self.init_load - self.load) / self.init_load220        self.resupplied = True221        self.load = self.init_load222223    def step(self):224        # 先把当前状态拷贝到 last_state,再清空“本步事件”字段。225        # 这样奖励函数可以同时访问上一步状态和本步事件。226        for key in self.last_state.__slots__:227            attr = getattr(self, key)228            setattr(self.last_state, key, attr)229230        self.dead = False231        self.new_task_found = False232        self.current_task_completed = False233        self.any_task_completed = False234        self.coalition_completed = False235        self.coalition_changed = False236        self.discovery = 0.0237        self.delivery = 0.0238        self.coalition_delivery = 0.0239        self.resupply_ratio = 0.0240        self.resupplied = False241        self.idle = False242243244class Task:245    """环境内部任务实体。246247    Deliver 任务初始不可见;Explore/Idle/Resupply 是辅助任务槽,始终可见。248    """249250    __slots__ = [251        'id', 'cap_dim', 'position', 'demand', 'initial_demand', 'urgency',252        'req_vec', 'req_ratio', 'type', 'discovered', 'completed',253        'discovered_step', 'first_assigned_step', 'response_time'254    ]255256    def __init__(self, cid: int, pos: np.ndarray, demand: int, urgency: float, cap_dim: int, rng=None):257        self.id = cid258        self.cap_dim = cap_dim259        self.position = np.array(pos, dtype=float)260        self.demand = demand261        self.initial_demand = demand262        self.urgency = urgency263        self.req_vec = ((rng.rand(cap_dim) if rng is not None else np.random.rand(cap_dim)) * (demand / 100.0)).astype(float)264        self.req_ratio = np.zeros(cap_dim)265        self.type = TaskType.Deliver266        self.discovered = False267        self.completed = False268269        self.discovered_step = None270        self.first_assigned_step = None271        self.response_time = None272273    def calc_req_ratio(self, cap_now):274        # req_ratio 是当前联盟能力相对需求的比例差,只用于 info/诊断,不直接进入局部观测。275        if self.discovered and not self.completed:276            req_vec = self.req_vec277            for dim in range(self.cap_dim):278                if np.isnan(req_vec[dim]) or req_vec[dim] <= 0.0:279                    self.req_ratio[dim] = 0.0280                else:281                    self.req_ratio[dim] = (cap_now[dim] - req_vec[dim]) / req_vec[dim]282        else:283            self.req_ratio = np.zeros(self.cap_dim)284285286287class EnvCore:288    """MACF 环境核心。289290    负责连续地图、智能体/任务状态推进、局部 Dict 观测、全局 critic state、奖励和终止条件。291    Gym wrapper 只负责把这里的返回值包装成向量环境接口。292    """293294    explore_task_id = -1295    idle_task_id = -1296    resupply_task_id = -1297298    def __init__(self, config):299        # scenario_name 决定是否启用 coalition_delivery 专用任务特征和奖励。300        scenario_name = getattr(config, "scenario_name", None)301        if scenario_name in (None, "", "MyEnv"):302            scenario_name = getattr(config, "env_name", "macf")303        self.scenario_name = scenario_name304        self.use_coalition_delivery = self.scenario_name == "coalition_delivery"305        self.coalition_delivery_reward_mode = getattr(306            config, "coalition_delivery_reward_mode", "baseline"307        )308        if self.coalition_delivery_reward_mode not in ("baseline", "shaped"):309            raise ValueError(310                "coalition_delivery_reward_mode must be 'baseline' or 'shaped', "311                f"got {self.coalition_delivery_reward_mode!r}"312            )313314        deliver_target_num = config.target_num315        # 探索动作可以是单一 nearest,也可以拆成多个 sector/survey 动作槽。316        self.explore_action_mode = getattr(config, "explore_action_mode", "nearest")317        if self.explore_action_mode not in ("nearest", "sector", "survey"):318            raise ValueError(f"Unsupported explore_action_mode: {self.explore_action_mode}")319        self.num_explore_actions = (320            int(getattr(config, "num_explore_actions", 1))321            if self.explore_action_mode in ("sector", "survey")322            else 1323        )324        if self.num_explore_actions <= 0:325            raise ValueError("num_explore_actions must be positive.")326        self._configured_explore_step_size = getattr(config, "explore_step_size", None)327        self.explore_random_jitter = float(getattr(config, "explore_random_jitter", 0.0))328329        self.explore_task_start = deliver_target_num330        self.explore_task_end = deliver_target_num + self.num_explore_actions331        # 任务槽顺序:真实配送任务 [0, target_num),探索槽,Idle 槽,Resupply 槽。332        self.__class__.explore_task_id = self.explore_task_start333        self.__class__.idle_task_id = self.explore_task_end334        self.__class__.resupply_task_id = self.explore_task_end + 1335        # 地图是连续二维空间,危险区域用圆心 + 半径表示。336        self.map_size = float(config.map_size)             # 连续地图边长 / m337        self.explore_step_size = (338            float(self._configured_explore_step_size)339            if self._configured_explore_step_size is not None340            else self.map_size * 0.25341        )342        # 危险圆几何参数;clearance 是绕行路径额外安全距离。343        self.danger_radius = float(getattr(config, 'danger_radius', 1.0))344        self.clearance = float(getattr(config, 'clearance', 0.5))345        # 仍保留 UAV/UGV 数量字段;总智能体数由二者相加得到。346        self.uav_num = config.num_uav               # 无人机数量347        self.ugv_num = config.num_ugv               # 无人车数量348        self.uav_perception_rg = tuple(config.uav_perception_rg[0:2])349        self.ugv_perception_rg = tuple(config.ugv_perception_rg[0:2])350        self.delivery_task_num = config.target_num351        self.task_num = config.target_num + self.num_explore_actions + 2352        self.gat_dim = config.gat_dim               # GAT 输出维度353        self.max_steps = config.max_steps           # 最大步数354        self.cap_dim = config.cap_dim               # 能力维度355356        self.reconfiguration_mode = str(357            getattr(config, "reconfiguration_mode", "legacy")358        )359        valid_reconfiguration_modes = {360            "legacy", "always", "periodic", "event", "urgency", "learned"361        }362        if self.reconfiguration_mode not in valid_reconfiguration_modes:363            raise ValueError(364                "reconfiguration_mode must be one of "365                f"{sorted(valid_reconfiguration_modes)}, got "366                f"{self.reconfiguration_mode!r}"367            )368        self.reconfiguration_interval = int(369            getattr(config, "reconfiguration_interval", 10)370        )371        if self.reconfiguration_interval <= 0:372            raise ValueError("reconfiguration_interval must be positive.")373        self.reconfiguration_urgency_threshold = float(374            getattr(config, "reconfiguration_urgency_threshold", 0.7)375        )376        self.reconfiguration_urgency_aggregation = str(377            getattr(config, "reconfiguration_urgency_aggregation", "mean")378        )379        if self.reconfiguration_urgency_aggregation not in ("mean", "sum"):380            raise ValueError(381                "reconfiguration_urgency_aggregation must be 'mean' or 'sum'."382            )383        self.reconfiguration_switch_cost = float(384            getattr(config, "reconfiguration_switch_cost", 0.0)385        )386        if self.reconfiguration_switch_cost < 0.0:387            raise ValueError("reconfiguration_switch_cost must be non-negative.")388389        self.reward_keys = ['switch_coalition', 'dead', 'discover', 'deliver', 'resupply', 'coalition_deliver', 'coalition_complete', 'dist', 'end']390391        # 总智能体数,后续 obs/buffer/policy 都以该值为 agent 维度。392        self.agent_num = self.uav_num + self.ugv_num393394        self.step_count = 0                 # 当前步数395        # ego 观测:位置(2)、能力(cap_dim)、当前联盟编号、当前联盟类型、396        # 载荷比例、已发现配送任务比例、电量比例。397        self.ego_feat_dim = self.cap_dim + 7398        if self.use_coalition_delivery:399            self.coalition_task_feature_mode = str(400                getattr(config, "coalition_task_feature_mode", "compact")401            )402            if self.coalition_task_feature_mode not in ("compact", "full"):403                raise ValueError(404                    "coalition_task_feature_mode must be 'compact' or 'full', got "405                    f"{self.coalition_task_feature_mode!r}"406                )407            if self.coalition_task_feature_mode == "full":408                # 位置、完整能力需求、配送量、期限、探索几何(3)、任务类型。409                self.per_task_feat = 2 + self.cap_dim + 1 + 1 + 3 + 1410            else:411                # 历史 6-D schema:位置、配送量、期限、req_vec[0]、任务类型。412                self.per_task_feat = 2 + 1 + 1 + 1 + 1413        else:414            self.coalition_task_feature_mode = "full"415            # 默认任务观测:位置、能力需求、剩余需求、任务类型。416            self.per_task_feat = 2 + self.cap_dim + 1 + 1417        # other 观测:位置、能力、载荷、电量、联盟编号、智能体类型、存活标志。418        self.other_agent_feat_dim = 2 + self.cap_dim + 1 + 1 + 1 + 1 + 1419        self.global_agent_feat_dim = self.other_agent_feat_dim420        # critic 的全局任务状态额外暴露 discovered/completed 标志。421        self.global_task_feat_dim = self.per_task_feat + 2422        self.global_feat_dim = 1423424        # flatten 后的 actor 观测维度;Dict 观测实际仍按 ego/tasks/others 三个 key 返回。425        self.obs_dim = (426            self.ego_feat_dim427            + self.task_num * self.per_task_feat428            + self.agent_num * self.other_agent_feat_dim429        )430431        # 动作维度等于任务槽数。Idle/Resupply/Explore 都已经是任务槽,不再额外增加动作。432        self.action_dim = self.task_num433434        # 事件标志只描述当前 step 内发生的关键事件,奖励和 active_mask 会读取它们。435        self.event_new_task_found       = 0436        self.event_task_completed       = [0 for i in range(self.agent_num)]437        self.event_any_task_completed   = 0438439        self.data_dir = 'data'440441        if config.rewards_config_file:442            with open(config.rewards_config_file, 'r', encoding='utf-8') as f:443                rewards_config = yaml.safe_load(f) or {}444        445            for key, value in rewards_config.items():446                setattr(self, key, value)447448        else:449450            self.pun_change_coalition_event = 0.001451            self.pun_change_coalition = 1452            self.w_short = 2.0   # 能力不足权重(重罚)453            self.w_surplus = 0.3 # 能力过多权重(轻罚)454            self.b_short = 8.0   # 能力不足权重(重罚)455            self.b_surplus = 20.0 # 能力过多权重(轻罚)456            self.rwd_ratio_discover_task = 5457            self.rwd_ratio_discover_dp = 1.0458            self.pun_ag_dead = 10459            self.rwd_ratio_send = 1460            self.rwd_coalition_ratio_send = 0.2461            self.rwd_task_completed  = 200462            self.rwd_coalition_task_completed = 50463            self.rwd_dist_ratio = 3.0464            self.pun_time_ratio = 0.1465466        # legacy GAT 的全局图状态占位;当前 HATT/MAPPO 主要使用 Dict state。467        self.global_state = np.zeros((self.agent_num, self.task_num, self.gat_dim))468469        # reset 相关缓存:jitter 模式复用基础场景,只扰动配送任务位置。470        self._seed = None471        self.rng = None472        self._base_initialized = False473        self._base_danger_points = None474        self._base_tasks = None475        self._base_agents = None476        self.task_position_jitter = float(getattr(config, 'task_position_jitter', 0.05))477        self.env_reset_mode = getattr(config, 'env_reset_mode', 'jitter')478479    def seed(self, seed):480        # 设置种子后清空基础场景缓存,保证下一次 reset 从该种子重新生成。481        self._seed = int(seed)482        self.rng = np.random.RandomState(self._seed)483        self._base_initialized = False484        self._base_danger_points = None485        self._base_tasks = None486        self._base_agents = None487        return [self._seed]488489    def _rand(self, *args):490        if self.rng is not None:491            return self.rng.rand(*args)492        return np.random.rand(*args)493494    def _randint(self, *args, **kwargs):495        if self.rng is not None:496            return self.rng.randint(*args, **kwargs)497        return np.random.randint(*args, **kwargs)498499    def _choice(self, a):500        if self.rng is not None:501            return self.rng.choice(a)502        return np.random.choice(a)503504    def _explore_sector_goal(self, ag):505        # sector 模式下,探索任务不是固定点,而是从当前位置朝某个扇区方向推进。506        sector = int(ag.coalition - self.explore_task_start)507        sector = max(0, min(self.num_explore_actions - 1, sector))508        angle, sector_width = self._explore_sector_angle(sector)509        if self.explore_random_jitter > 0.0:510            jitter = (float(self._rand()) * 2.0 - 1.0) * self.explore_random_jitter * sector_width511            angle += jitter512        direction = np.array([np.cos(angle), np.sin(angle)], dtype=float)513        goal = ag.position + direction * self.explore_step_size514        return np.clip(goal, 0.0, self.map_size)515516    def _explore_sector_angle(self, sector):517        sector_width = 2.0 * np.pi / float(self.num_explore_actions)518        return sector * sector_width, sector_width519520    def _explore_sector_anchor(self, sector):521        angle, _ = self._explore_sector_angle(sector)522        direction = np.array([np.cos(angle), np.sin(angle)], dtype=float)523        center = np.array([0.5 * self.map_size, 0.5 * self.map_size], dtype=float)524        return np.clip(center + direction * (0.45 * self.map_size), 0.0, self.map_size)525526    def _survey_anchor(self, survey_idx):527        # survey 模式将地图划分成近似网格,每个探索动作对应一个固定巡查锚点。528        cols = int(np.ceil(np.sqrt(self.num_explore_actions)))529        rows = int(np.ceil(self.num_explore_actions / cols))530        col = survey_idx % cols531        row = survey_idx // cols532        x = (col + 1.0) / (cols + 1.0) * self.map_size533        y = (row + 1.0) / (rows + 1.0) * self.map_size534        return np.array([x, y], dtype=float)535536    def _is_explore_task_id(self, task_id):537        # 判断某个任务槽是否属于探索动作槽。538        return self.explore_task_start <= int(task_id) < self.explore_task_end539540    def _save_base_env(self):541        # jitter reset 的基础场景快照,包括危险点、任务和智能体初始状态。542        self._base_danger_points = copy.deepcopy(self.danger_points)543        self._base_tasks = copy.deepcopy(self.tasks)544        self._base_agents = copy.deepcopy(self.agents)545546    def _restore_base_env(self):547        # 从基础场景快照恢复,避免每个 episode 完全重采样。548        self.danger_points = copy.deepcopy(self._base_danger_points)549        self.tasks = copy.deepcopy(self._base_tasks)550        self.agents = copy.deepcopy(self._base_agents)551552    def _jitter_task_positions(self):553        # 只扰动配送任务位置;探索/空闲/补给槽保持固定语义。554        if self.task_position_jitter <= 0.0:555            return556        scale = self.task_position_jitter * self.map_size557        for task in self.tasks:558            if task.type != TaskType.Deliver:559                continue560            base_pos = task.position.copy()561            for _ in range(20):562                offset = self._rand(2) * 2.0 * scale - scale563                new_pos = np.clip(base_pos + offset, 0.0, self.map_size)564                if not any(565                    np.linalg.norm(new_pos - np.array(dp['position'])) <= (self.danger_radius + self.clearance)566                    for dp in self.danger_points567                ):568                    task.position = new_pos569                    break570            else:571                task.position = np.clip(base_pos, 0.0, self.map_size)572573    def _init_map(self):574        # 连续地图:只记录危险点圆心575        self.danger_points = []576        # 危险点数量目前固定,历史代码里保留过按面积缩放的方案。577        num_danger = 10#max(1, int(self.map_size * self.map_size * 0.002))578        for _ in range(num_danger):579            p = (self._rand(2) * self.map_size).astype(float)580            self.danger_points.append({581                'position': p,582                'discovered': False583            })584585    def _init_agents(self):586        # 随机生成智能体能力和位置;位置不能落在危险圆安全范围内。587        self.agents: list[Agent] = []588        for i in range(self.agent_num):589            capbilities = self._rand(self.cap_dim)590            # 采样连续位置,避开危险点中心591            pos = self._rand(2) * self.map_size592            # 保证不在危险半径内593            tries = 0594            while any(np.linalg.norm(pos - np.array(dp['position'])) <= (self.danger_radius + self.clearance) for dp in self.danger_points) and tries < 50:595                pos = self._rand(2) * self.map_size596                tries += 1597598            agent = Agent(i, pos, capbilities, rng=self.rng)599600            self.agents.append(agent)601602    def _init_tasks(self):603        '''随机放置任务,并追加探索、空闲、补给三个类别的辅助任务槽。'''604        self.tasks: list[Task] = []605606        # 先生成所有任务槽,后面会覆盖探索/空闲/补给槽。607        for i in range(self.task_num):608            demand_val = int(self._randint(100, 500))  # 50–100609            pos = self._rand(2) * self.map_size610            tries = 0611            while any(np.linalg.norm(pos - np.array(dp['position'])) <= (self.danger_radius + self.clearance) for dp in self.danger_points) and tries < 200:612                pos = self._rand(2) * self.map_size613                tries += 1614            task = Task(i, np.array(pos, dtype=float), demand_val, self._rand(), self.cap_dim, rng=self.rng)615            if self.use_coalition_delivery and i < self.delivery_task_num:616                deadline_frac = 0.45 + 0.5 * float(self._rand())617                priority = 0.5 + 0.5 * float(self._rand())618                task.urgency = deadline_frac619                task.req_vec = np.array([priority, 0.0, 0.0, 0.0], dtype=float)620            self.tasks.append(task)621622        # 覆盖探索槽。nearest 模式下探索槽位置为 NaN,step 时动态寻找最近未发现任务。623        for sector, explore_id in enumerate(range(self.explore_task_start, self.explore_task_end)):624            if self.explore_action_mode == "survey":625                explore_pos = self._survey_anchor(sector)626            elif self.explore_action_mode == "sector":627                explore_pos = self._explore_sector_anchor(sector)628            else:629                explore_pos = np.array((np.nan, np.nan))630            exploring_task = Task(explore_id, explore_pos, 0, 0.5, self.cap_dim)631            exploring_task.req_vec = np.array([0.25, 0, 0, 0], dtype=float)632            exploring_task.type = TaskType.Explore633            exploring_task.discovered = True634            self.tasks[explore_id] = exploring_task635636        # Idle 槽始终可选,但没有空间目标。637        idle_task = Task(self.__class__.idle_task_id, np.array((np.nan, np.nan)), 0, 0, self.cap_dim)638        idle_task.req_vec = np.array([0, 0, 0, 0])639        idle_task.type = TaskType.Idle640        idle_task.discovered = True641        self.tasks[self.__class__.idle_task_id] = idle_task642643        # Resupply 槽固定在原点,智能体到达后恢复载荷和电量。644        resupply_task = Task(self.__class__.resupply_task_id, np.array((0, 0)), 0, 0, self.cap_dim)645        resupply_task.req_vec = np.array([0, 0, 0, 0])646        resupply_task.type = TaskType.Resupply647        resupply_task.discovered = True648        self.tasks[self.__class__.resupply_task_id] = resupply_task649650651    def set_reset_mode(self, mode):652        """动态切换 reset 模式,课程学习会调用。"""653        assert mode in ("jitter", "full_random")654        self.env_reset_mode = mode655656    def reset(self, episode=-1):657        self.step_count = 0658        if self.env_reset_mode == "full_random":659            # 每个 episode 完全重采样地图、任务和智能体。660            self._init_map()661            self._init_tasks()662            self._init_agents()663        elif not self._base_initialized:664            # jitter 模式第一次 reset 先生成基础场景并缓存。665            self._init_map()666            self._init_tasks()667            self._init_agents()668            self._save_base_env()669            self._base_initialized = True670        else:671            # jitter 模式后续 episode 恢复基础场景,只扰动配送任务位置。672            self._restore_base_env()673            self._jitter_task_positions()674        # sent_goal 包含所有任务槽的初始需求;辅助任务需求为 0,不影响总配送目标。675        self.sent_goal = np.sum([task.initial_demand for task in self.tasks])676        self.event_new_task_found = 0677        self.event_task_completed = [0 for i in range(self.agent_num)]678        self.event_any_task_completed = 0679        self.global_state = np.zeros((self.agent_num, self.task_num, self.gat_dim))680681        self.total_coalition_changes = 0682        self.total_reconfiguration_decisions = 0683        self._current_decision_mask = np.ones(684            (self.agent_num,), dtype=np.float32685        )686        self.total_alive_agent_steps = 0687        self.response_times = []688689        if episode != -1:690            self.episode = episode691        observations = self._get_observations()692693        active_mask = np.ones((self.agent_num,), dtype=np.float32)694        active_mask = active_mask[..., None]695696        available_actions = self._get_available_actions()697698        return observations, active_mask, available_actions#, info699700    def _get_available_actions(self):701        # 当前策略只能选择“已发现且未完成”的任务槽。702        # Explore/Idle/Resupply 在初始化时设置为 discovered=True,因此一般保持可选。703        task_mask = np.fromiter(704            (1.0 if task.discovered and not task.completed else 0.0 for task in self.tasks),705            dtype=np.float32,706            count=self.task_num,707        )708        return np.repeat(task_mask[None, :], self.agent_num, axis=0)709710    def _reconfiguration_urgency_pressure(self):711        """Return the configured aggregate pressure of visible unfinished tasks.712713        A task's pressure grows from ``1-deadline`` at reset to one at its714        deadline.  ``mean`` is scale invariant and is the recommended setting;715        ``sum`` reproduces the scale-sensitive trigger proposed in the draft.716        """717718        time_ratio = self.step_count / max(1.0, float(self.max_steps))719        pressures = []720        for task in self.tasks:721            if (722                task.type != TaskType.Deliver723                or not task.discovered724                or task.completed725            ):726                continue727            deadline_left = max(0.0, float(task.urgency) - time_ratio)728            pressures.append(1.0 - min(1.0, deadline_left))729        if not pressures:730            return 0.0731        if self.reconfiguration_urgency_aggregation == "sum":732            return float(np.sum(pressures))733        return float(np.mean(pressures))734735    def _get_reconfiguration_active_mask(self, key_event, all_still):736        """Select agents that receive a fresh policy decision next step."""737738        living = np.array([ag.living for ag in self.agents], dtype=bool)739        completed = np.array(740            [ag.living and ag.current_task_completed for ag in self.agents],741            dtype=bool,742        )743        if all_still:744            return living.astype(np.float32)745746        mode = self.reconfiguration_mode747        if mode == "legacy":748            return np.array(749                [750                    1.0751                    if ag.living752                    and (753                        ag.current_task_completed754                        or (ag.idle and key_event)755                    )756                    else 0.0757                    for ag in self.agents758                ],759                dtype=np.float32,760            )761        if mode == "always":762            return living.astype(np.float32)763764        interval_due = self.step_count % self.reconfiguration_interval == 0765        if mode == "periodic":766            reconsider_all = interval_due767        elif mode == "event":768            reconsider_all = key_event769        elif mode == "urgency":770            reconsider_all = (771                self._reconfiguration_urgency_pressure()772                >= self.reconfiguration_urgency_threshold773            )774        else:  # learned: HATT gate filters periodic/event candidate windows.775            reconsider_all = interval_due or key_event776777        return np.where(reconsider_all, living, completed).astype(np.float32)778779    def discover_along_segment(self, agent_idx, a, b):780        # 沿移动线段检查感知范围,标记新发现的配送任务和危险点。781        # 使用点到线段距离,而不是只看终点距离,避免高速移动穿过目标但漏检。782        ag = self.agents[agent_idx]783        per = float(ag.perception)784        discovered_tasks = []785        discovered_dp = 0786787        a = np.asarray(a, dtype=float)788        b = np.asarray(b, dtype=float)789        ab = b - a790        denom = float(np.dot(ab, ab))791792        def point_segment_distances(points):793            if len(points) == 0:794                return np.empty((0,), dtype=float)795            if denom <= eps:796                return np.linalg.norm(points - a[None, :], axis=1)797            t = ((points - a[None, :]) @ ab) / denom798            t = np.clip(t, 0.0, 1.0)799            proj = a[None, :] + t[:, None] * ab[None, :]800            return np.linalg.norm(points - proj, axis=1)801802        task_ids = [803            idx804            for idx, task in enumerate(self.tasks)805            if (not task.discovered)806            and task.type == TaskType.Deliver807            and not np.any(np.isnan(task.position))808        ]809        if task_ids:810            task_positions = np.stack([self.tasks[idx].position for idx in task_ids])811            discovered_mask = point_segment_distances(task_positions) <= per812            for idx in np.asarray(task_ids, dtype=np.int64)[discovered_mask]:813                task = self.tasks[int(idx)]814                task.discovered = True815                task.discovered_step = self.step_count816                discovered_tasks.append(int(idx))817818        danger_ids = [819            idx for idx, dp in enumerate(self.danger_points) if not dp["discovered"]820        ]821        if danger_ids:822            danger_positions = np.stack(823                [self.danger_points[idx]["position"] for idx in danger_ids]824            )825            discovered_mask = point_segment_distances(danger_positions) <= per826            for idx in np.asarray(danger_ids, dtype=np.int64)[discovered_mask]:827                self.danger_points[int(idx)]["discovered"] = True828                discovered_dp += 1829        return discovered_tasks, discovered_dp830831    def move_along_path(self, path_pts, start_pos, max_dist):832        # path_pts 是从当前位置到目标的路点列表;max_dist 是本步最多可移动距离。833        # 返回新位置和剩余路径。剩余路径首点始终是新位置。834        pos = np.array(start_pos, dtype=float)835        rem = []836        # 保证路径从当前位置开始,避免上一帧路径首点和当前位置存在微小偏差。837        pts = [tuple(pos)] + [tuple(p) for p in path_pts[1:]] if len(path_pts) > 0 else [tuple(pos)]838        dist_left = float(max_dist)839        i = 0840        while dist_left > 1e-8 and i < len(pts)-1:841            a = np.array(pts[i], dtype=float)842            b = np.array(pts[i+1], dtype=float)843            seg_len = np.linalg.norm(b - a)844            if seg_len <= 1e-8:845                i += 1846                continue847            need = min(seg_len - np.linalg.norm(pos - a), dist_left)848            # 如果位置不在线段开头,计算剩下在线段上还有多长849            to_end = np.linalg.norm(b - pos)850            move = min(to_end, dist_left)851            if move >= to_end - 1e-8:852                # 到达当前线段终点,继续走下一段。853                pos = b854                dist_left -= to_end855                i += 1856            else:857                # 本步距离不足以到达线段终点,停在线段中间。858                direction = (b - pos) / np.linalg.norm(b - pos)859                pos = pos + direction * move860                dist_left = 0.0861                break862        # 剩余路径:从当前位置开始,然后接上未走完的后续路点。863        remaining = [tuple(pos)]864        for j in range(i+1, len(pts)):865            remaining.append(tuple(pts[j]))866        return pos, remaining867868869    def step(self, actions):870        '''871        参数872        -------873    actions : List[int]  长度 = self.agent_num  874        每个元素为智能体选中的任务 ID  875        动作语义:0..(task_num-1) 对应任务 ID,task_num 表示 Idle(空闲,不选择任何任务)。876        为兼容旧版,也接受 -1 表示 Idle。877        返回878        -------879        observations : dict        {'ego':..., 'tasks':..., 'others':...}880        rewards      : np.ndarray  [N_agents]881        dones        : np.ndarray  [N_agents]  (全部任务完成或到 max_steps)882        info         : dict        调试、评估和日志指标883        '''884        from datetime import datetime885        d = d0 = datetime.now().timestamp()886        self.step_count += 1887888        step_reconfiguration_decisions = int(889            np.count_nonzero(self._current_decision_mask > 0.5)890        )891        self.total_reconfiguration_decisions += step_reconfiguration_decisions892        self.total_alive_agent_steps += int(sum(ag.living for ag in self.agents))893894        new_discovered = set()895896        self.t = np.array([0, 0, 0, 0, 0], float)897898        # 1. 联盟决策:动作直接写入每个智能体的 coalition。899        # available_actions 在 wrapper/policy 侧限制可选任务;这里仍容忍越界动作并忽略。900        for i, raw_a in enumerate(actions):901            # 兼容 raw_a 是 tensor 或普通 int 的情况902            try:903                a = int(raw_a.item())904            except Exception:905                a = int(raw_a)906907            # 当前动作范围是 [0, task_num),其中 Idle 已经是一个普通任务槽。908            if a < 0 or a >= self.task_num:909                # 非法动作,忽略910                continue911912            self.agents[i].act(a)913            # 若选择非法任务 ID,忽略,保持原状态914915        step_coalition_changes = int(916            sum(ag.coalition_changed for ag in self.agents)917        )918        self.total_coalition_changes += step_coalition_changes919920        # 2. 首次响应时间:任务被发现后,第一次有活着的智能体加入该任务联盟时记录。921        for task in self.tasks:922            if task.type != TaskType.Deliver:923                continue924            if not task.discovered or task.completed:925                continue926            if task.discovered_step is None:927                continue928            if task.first_assigned_step is not None:929                continue930931            assigned = any(932                ag.living and ag.coalition == task.id933                for ag in self.agents934            )935            if assigned:936                task.first_assigned_step = self.step_count937                task.response_time = task.first_assigned_step - task.discovered_step938                self.response_times.append(task.response_time)939940        # 3. 清空本步事件标志;后续发现、完成、死亡等逻辑会重新写入。941        self.event_new_task_found      = 0942        self.event_task_completed      = [0 for i in range(self.agent_num)]943        self.event_any_task_completed  = 0944945        task_caps = np.zeros((self.task_num, self.cap_dim))946        coalition_sizes = np.zeros(self.task_num, dtype=int)947        coalitions = [[] for i in range(self.task_num)]948949        # 4. 依据当前 coalition 聚合每个任务槽上的联盟成员能力和成员列表。950        # 这份聚合既用于任务完成逻辑,也用于奖励/诊断中的能力匹配度。951        for i, ag in enumerate(self.agents):952            c = ag.coalition953            if c is None or c == -1 or c == self.task_num:954                continue955            task_caps[c] += ag.capbilities956            coalition_sizes[c] += 1957            coalitions[c].append(ag.aid)958959        # 5. 联盟-任务能力匹配度诊断。960        # 当前惩罚分摊代码仍保留但未实际写入 rewards,用于后续恢复或分析。961962        for j, task in enumerate(self.tasks):963            # 不考虑 Idle 任务和补给任务964            if j == self.__class__.idle_task_id or j == self.__class__.resupply_task_id:965                continue966            # 只对 active 的、未完成的任务算匹配度967            if coalition_sizes[j] == 0:968                continue969            if not task.completed:970                continue971972            req = task.req_vec973            if req is None:974                continue975976            cap = task_caps[j]977            min_val = 0.2978            # 避免除零979            denom = np.where((cap > -min_val) & (cap < min_val), min_val, cap)980981            diff = cap - req982            shortage = np.clip(-diff, 0.0, None)   # cap < req 的部分983            surplus  = np.clip( diff, 0.0, None)   # cap > req 的部分984985            # 按平方惩罚,让严重不匹配的惩罚更大986            penalty_j = (987                self.w_short  * (float(np.sum(shortage ** 2)) - self.b_short) +988                self.w_surplus * (float(np.sum(surplus ** 2)) - self.b_surplus)989            )990991            if penalty_j <= 0:992                continue993994            # 把惩罚均摊给联盟内所有 agent995            share = penalty_j / float(coalition_sizes[j])996997            share = np.clip(share, -100, 2)998999            # for i in coalitions[j]:1000            #     rewards[i] -= share10011002        d = datetime.now().timestamp()1003        self.t[0] = d - d01004        d0 = d1005        1006        # rewards_by_step[1] = rewards - rewards_by_step[0]10071008        # 6. 为没有剩余路径的非空闲智能体生成目标点和路径。1009        # 已有路径的智能体继续执行旧路径,避免每步重规划造成抖动。10101011        danger_positions = [dp['position'] for dp in self.danger_points]10121013        for i, ag in enumerate(self.agents):10141015            # 死亡智能体不规划路径。1016            if not ag.living:1017                continue10181019            # 未走完旧路径时不重新分配路径。1020            if len(ag.path) != 0:1021                continue10221023            task = self.tasks[ag.coalition]10241025            # Idle 槽没有空间目标。1026            if task.type == TaskType.Idle:1027                continue10281029            # 根据任务类型确定目标点:探索任务可动态选择,配送/补给使用任务位置。1030            if task.type == TaskType.Explore:1031                if self.explore_action_mode == "survey":1032                    goal = np.array(task.position, dtype=float)1033                elif self.explore_action_mode == "sector":1034                    goal = self._explore_sector_goal(ag)1035                else:1036                    # nearest 探索:优先朝最近的未发现配送任务移动。1037                    cand_tasks = [task for task in self.tasks if (not task.discovered) and (task.type == TaskType.Deliver)]1038                    if cand_tasks:1039                        # 按欧氏距离排序,选择最近未发现配送任务。1040                        cand_tasks.sort(key=lambda task: np.linalg.norm(task.position - ag.position))1041                        goal = tuple(cand_tasks[0].position)1042                    else:1043                        # 若没有未发现配送任务,则随机探索一个地图点。1044                        goal = tuple((self._rand(2) * self.map_size))10451046                    goal = np.array(goal, dtype=float)10471048            else: # elif coalition.type == TaskType.Deliver or coalition.type == TaskType.Resupply:10491050                goal = task.position1051                1052            # 根据危险点生成绕行路径。1053            ag.path = fast_path(ag.position, goal, danger_positions, danger_radius=self.danger_radius, clearance=self.clearance)10541055            # 理论上 fast_path 很少返回空;若失败,本步该智能体不移动。1056            if len(ag.path) == 0:1057                print(f'step {self.step_count}, agent {i}: cannot find path for exploration agent')10581059        # 7. 沿路径移动,并在移动线段上触发探索、交付和补给事件。10601061        sent = 01062        completed = 010631064        for i, ag in enumerate(self.agents):10651066            # 死亡智能体不移动。1067            if not ag.living:1068                continue10691070            # 没有路径说明没有空间目标或规划失败。1071            if len(ag.path) == 0:1072                continue10731074            task = self.tasks[ag.coalition]10751076            # Idle 智能体保持当前位置。1077            if task.type == TaskType.Idle:1078                continue10791080            # 只要执行移动就消耗电量。1081            ag.consume()10821083            # mobility 决定本步沿路径最多前进多远。1084            start = ag.position1085            new_pos, remaining = self.move_along_path(ag.path, start, ag.mobility)1086            # 移动过程中可能发现新任务或危险点。1087            discovered_tasks, discovered_dp = self.discover_along_segment(i, start, new_pos)10881089            # 新发现任务会激活探索奖励,并触发 active_mask 让部分智能体重新决策。1090            if len(discovered_tasks) > 0:1091                ag.discovery = len(discovered_tasks) + 0.1 * discovered_dp1092                self.event_new_task_found = 11093                for task_id in discovered_tasks:1094                    new_discovered.add(task_id)10951096            ag.position = np.array(new_pos, dtype=float)10971098            # 如果路径已经走到终点,则清空路径。1099            ag.path = remaining if len(remaining) > 1 else []11001101            # 配送/补给任务记录到目标的距离,距离奖励依赖 last_state.dist - dist。1102            if task.type in [TaskType.Deliver, TaskType.Resupply]:1103                dist = np.linalg.norm(ag.position - task.position)1104                ag.dist = float(dist)11051106            if task.type == TaskType.Explore:1107                if self.explore_action_mode == "survey" and len(ag.path) == 0:1108                    task.completed = True1109                    ag.current_task_completed = True1110                elif len(discovered_tasks) > 0:#ag.unchanged_time >= 10:1111                    ag.current_task_completed = True11121113            # 配送智能体到达任务点后消耗载荷并减少任务 demand。1114            elif task.type == TaskType.Deliver:1115                if not np.any(np.isnan(task.position)) and ag.dist < 1e-3:1116                    ag.complete()1117                    send_amount = ag.deliver(task.demand)1118                    sent += send_amount1119                    task.demand -= send_amount1120                    # 交付量作为联盟贡献同步给同联盟成员,用于联盟运输奖励。1121                    for i in coalitions[task.id]:1122                        self.agents[i].coalition_delivery += ag.delivery11231124                    if task.demand <= 0:1125                        task.completed = True1126                        completed += 11127                        1128                        # 任务完成时,同联盟成员都获得完成事件。1129                        for i in coalitions[task.id]:1130                            self.event_task_completed[i] = 11131                            self.agents[i].coalition_completed = True1132                            self.agents[i].complete()11331134                            self.event_any_task_completed = 111351136            # 补给智能体到达补给点后恢复载荷和电量。1137            elif task.type == TaskType.Resupply:1138                if not np.any(np.isnan(task.position)) and ag.dist < 1e-3:1139                    ag.complete()1140                    ag.resupply()11411142        d = datetime.now().timestamp()1143        self.t[3] = d - d01144        d0 = d11451146        if len(new_discovered) > 0:1147            # 探索发现新任务后,清空探索智能体路径,让它们可以尽快重新决策。1148            for ag in self.agents:1149                if self.tasks[ag.coalition].type == TaskType.Explore:1150                    ag.path = []11511152        # 历史时间惩罚位置保留在这里;当前实际奖励在 _get_rewards 中统一计算。1153        # for i, ag in enumerate(self.agents):1154        #     if ag['living']:1155        #         # 时间惩罚1156        #         rewards[i] -= self.pun_time_ratio * (self.step_count / self.max_steps) # 时间惩罚1157            1158        # 8. 生成下一时刻局部观测。奖励在后面算,但观测已经反映移动/任务状态更新。1159        observations = self._get_observations()11601161        # 9. 终止条件与 active_mask。1162        # done 表示环境回合终止或单个智能体死亡;bad_mask 区分自然终止和时间截断。1163        1164        # key_event 会触发空闲且存活的智能体重新决策。1165        key_event = (self.event_new_task_found == 1 or1166                     self.event_any_task_completed == 1)1167        1168        all_task_completed = all(task.completed for task in self.tasks if task.type == TaskType.Deliver)1169        all_still = all([not ag.living or ag.idle for ag in self.agents])11701171        all_agents_dead = all(not ag.living for ag in self.agents)1172        natural_done = all_task_completed or all_agents_dead1173        time_limit = self.step_count >= self.max_steps11741175        episode_done = natural_done or time_limit1176        dones = np.array([episode_done or not ag.living for ag in self.agents])1177        bad_mask = np.array([0.0 if time_limit and not natural_done else 1.0 for ag in self.agents])1178        bad_mask = bad_mask[..., None]11791180        if episode_done:1181            # episode 结束时所有 agent 都激活,方便 runner flush 最后一帧。1182            active_mask = np.ones((len(self.agents),), dtype=np.float32)1183        else:1184            active_mask = self._get_reconfiguration_active_mask(1185                key_event=key_event,1186                all_still=all_still,1187            )1188        active_mask = active_mask[..., None]1189        self._current_decision_mask = active_mask[:, 0].copy()11901191        available_actions = self._get_available_actions()11921193        d = datetime.now().timestamp()1194        self.t[4] = d - d01195        d0 = d11961197        # 10. 奖励和任务能力比例诊断。11981199        if self.event_any_task_completed == 1:1200            for ag in self.agents:1201                ag.any_task_completed = True12021203        for i, task in enumerate(self.tasks):1204            # req_ratio 反映当前联盟能力相对需求的富余/缺口,写入任务对象供诊断使用。1205            task.calc_req_ratio(task_caps[i,:])12061207        rewards_ret, rewards_info = self._get_rewards(sent, completed, new_discovered, all_task_completed, all_still)12081209        if self.reconfiguration_switch_cost > 0.0:1210            switched = np.array(1211                [1212                    ag.coalition_changed1213                    and not ag.last_state.idle1214                    and not self.tasks[int(ag.last_state.coalition)].completed1215                    for ag in self.agents1216                ],1217                dtype=np.float32,1218            )1219            extra_switch_penalty = self.reconfiguration_switch_cost * switched1220            rewards_ret[:, 0] -= extra_switch_penalty1221            rewards_info[self.reward_keys.index("switch_coalition")] -= (1222                extra_switch_penalty1223            )12241225        if key_event:1226            pass12271228        # 11. info 主要供日志、评估和离线分析;不进入策略网络。1229        info = {1230            'agents': [{1231                'position': ag.position,1232                'coalition': ag.coalition,1233                'living': ag.living,1234                'capabilities': ag.capbilities,1235                'load': ag.load,1236                'unchanged': ag.unchanged_time,1237                } for ag in self.agents],1238            'tasks': [{1239                'position': task.position,1240                'req_vec': task.req_vec,1241                'demand': task.demand,1242                'urgency': task.urgency,1243                'initial_demand': task.initial_demand,1244                'type': int(task.type),1245                'discovered': task.discovered,1246                'completed': task.completed1247                } for task in self.tasks],1248            'danger_points': self.danger_points,1249            't': self.t,1250            'rewards': rewards_info,1251            'reward_keys': self.reward_keys,1252            'sent': sent,1253            'sent_goal': self.sent_goal,1254            'agent_caps': np.stack([ag.capbilities for ag in self.agents]),1255            'task_reqs': np.stack([task.req_vec for task in self.tasks]),1256            'reconfiguration': {1257                'mode': self.reconfiguration_mode,1258                'decisions': step_reconfiguration_decisions,1259                'coalition_changes': step_coalition_changes,1260                'total_decisions': self.total_reconfiguration_decisions,1261                'total_coalition_changes': self.total_coalition_changes,1262                'total_alive_agent_steps': self.total_alive_agent_steps,1263                'urgency_pressure': self._reconfiguration_urgency_pressure(),1264                'mean_response_time': float(np.mean(self.response_times))1265                if self.response_times else 0.0,1266            },1267            'end': self.step_count + 1 if episode_done else 01268        }12691270        for ag in self.agents:1271            # 最后更新 last_state,并清空本步事件,准备下一次 step。1272            ag.step()127312741275        return observations, rewards_ret, dones, info, active_mask, bad_mask, available_actions127612771278    def _task_feature_matrix(self, include_flags=False, local_visible=False):1279        """构造任务特征矩阵。12801281        local_visible=True 时,未发现或已完成任务在局部观测中置零;1282        include_flags=True 时,为 critic 额外添加 discovered/completed 标志。1283        """12841285        extra_dim = 2 if include_flags else 01286        features = np.zeros((self.task_num, self.per_task_feat + extra_dim), dtype=np.float32)1287        for idx, task in enumerate(self.tasks):1288            if local_visible and (not task.discovered or task.completed):1289                continue1290            pos = task.position1291            if np.isnan(pos[0]) or np.isnan(pos[1]):1292                # Idle 或 nearest Explore 这类无固定空间目标的任务用 0 位置占位。1293                pos = np.array([0.0, 0.0])1294            if self.use_coalition_delivery:1295                features[idx, 0:2] = pos / self.map_size1296                if self.coalition_task_feature_mode == "full":1297                    features[idx, 2:2 + self.cap_dim] = task.req_vec / 5.01298                    features[idx, 2 + self.cap_dim] = task.demand / 500.01299                    deadline_idx = 3 + self.cap_dim1300                    type_idx = 7 + self.cap_dim1301                else:1302                    features[idx, 2] = task.demand / 500.01303                    features[idx, 4] = (1304                        float(task.req_vec[0]) if task.req_vec is not None else 0.01305                    )1306                    deadline_idx = 31307                    type_idx = 51308                if task.type == TaskType.Deliver:1309                    features[idx, deadline_idx] = max(1310                        0.0,1311                        float(task.urgency)1312                        - self.step_count / max(1.0, self.max_steps),1313                    )1314                else:1315                    features[idx, deadline_idx] = 1.01316                features[idx, type_idx] = float(task.type) / float(max(TaskType))1317            else:1318                # 默认 MACF 任务特征:位置、能力需求、剩余需求、任务类型。1319                # req_vec / 5.0 与 HATT residual 中的 hatt_capability_scale 对应。1320                features[idx, 0:2] = pos / self.map_size1321                features[idx, 2:2 + self.cap_dim] = task.req_vec / 5.01322                features[idx, 2 + self.cap_dim] = task.demand / 500.01323                features[idx, 3 + self.cap_dim] = float(task.type) / float(max(TaskType))1324            if include_flags:1325                # 这两个 flag 只给 critic 全局状态,不给 actor 局部任务观测。1326                features[idx, self.per_task_feat] = 1.0 if task.discovered else 0.01327                features[idx, self.per_task_feat + 1] = 1.0 if task.completed else 0.01328        return features13291330    def _agent_feature_matrix(self):1331        """构造所有智能体的状态矩阵,用于 other 观测和 critic 全局状态。"""13321333        features = np.zeros((self.agent_num, self.other_agent_feat_dim), dtype=np.float32)1334        max_coalition = float(max(1, self.task_num - 1))1335        max_agent_type = float(max(AgentType))1336        for idx, ag in enumerate(self.agents):1337            # 注意:这里保留 agent 自己;_get_observations 会在 others 中把对角线置零。1338            features[idx, 0:2] = ag.position / self.map_size1339            features[idx, 2:2 + self.cap_dim] = ag.capbilities1340            features[idx, 2 + self.cap_dim] = float(ag.load) / float(ag.init_load)1341            features[idx, 3 + self.cap_dim] = ag.battery / 1000.01342            features[idx, 4 + self.cap_dim] = float(ag.coalition) / max_coalition1343            features[idx, 5 + self.cap_dim] = float(ag.type) / max_agent_type1344            features[idx, 6 + self.cap_dim] = 1.0 if ag.living else 0.01345        return features13461347    def _get_observations(self):1348        """返回 actor 使用的局部 Dict 观测。13491350        ego:    每个 agent 自己的状态,shape [A, ego_dim]1351        tasks:  每个 agent 看到的任务槽,shape [A, T, task_dim]1352        others: 每个 agent 看到的其他智能体,shape [A, A, other_dim],自身位置置零1353        """13541355        task_features = self._task_feature_matrix(local_visible=True)1356        # 当前任务可见性对所有 agent 相同,因此复制到 observer 维。1357        task_obs = np.repeat(task_features[None, :, :], self.agent_num, axis=0)13581359        agent_features = self._agent_feature_matrix()1360        other_obs = np.repeat(agent_features[None, :, :], self.agent_num, axis=0)1361        self_indices = np.arange(self.agent_num)1362        # others 的第 idx 个 observer 不应看到“另一个自己”,对角线置零。1363        other_obs[self_indices, self_indices, :] = 0.013641365        discovered_deliver = sum(1366            1 for task in self.tasks1367            if task.discovered and task.type == TaskType.Deliver1368        )1369        discovered_ratio = float(discovered_deliver) / float(self.delivery_task_num)1370        max_coalition = float(max(1, self.task_num - 1))1371        max_task_type = float(max(TaskType))1372        ego_obs = np.zeros((self.agent_num, self.ego_feat_dim), dtype=np.float32)1373        for idx, ag in enumerate(self.agents):1374            coalition = self.tasks[ag.coalition]1375            # ego 中保留当前联盟编号和联盟类型,让 actor 知道当前正在执行的任务语义。1376            ego_obs[idx, 0:2] = ag.position / self.map_size1377            ego_obs[idx, 2:2 + self.cap_dim] = ag.capbilities1378            ego_obs[idx, 2 + self.cap_dim] = float(ag.coalition) / max_coalition1379            ego_obs[idx, 3 + self.cap_dim] = float(coalition.type) / max_task_type1380            ego_obs[idx, 4 + self.cap_dim] = float(ag.load) / float(ag.init_load)1381            ego_obs[idx, 5 + self.cap_dim] = discovered_ratio1382            ego_obs[idx, 6 + self.cap_dim] = ag.battery / 1000.013831384        return {1385            "ego": ego_obs,1386            "tasks": task_obs.astype(np.float32, copy=False),1387            "others": other_obs.astype(np.float32, copy=False),1388        }13891390    def get_global_state(self):1391        """返回 critic 使用的特权全局状态。13921393        actor 只能看到局部可见 tasks;critic 可以看到所有任务及 discovered/completed 标志。1394        """13951396        return {1397            "agents": self._agent_feature_matrix(),1398            "tasks": self._task_feature_matrix(include_flags=True),1399            "global": np.array([self.step_count / self.max_steps], dtype=np.float32),1400        }14011402    def _get_rewards(self, sent, completed, new_discovered, all_task_completed, all_still):1403        """选择当前场景的奖励函数,并返回每个 agent 的总奖励和分项奖励。"""14041405        if self.use_coalition_delivery:1406            if self.coalition_delivery_reward_mode == "shaped":1407                return self._get_coalition_delivery_rewards_shaped(1408                    sent, completed, new_discovered, all_task_completed, all_still1409                )1410            return self._get_coalition_delivery_rewards(1411                sent, completed, new_discovered, all_task_completed, all_still1412            )14131414        # 默认奖励:保留早期探索/配送/补给/完成/距离塑形逻辑。1415        any_explore = any(self._is_explore_task_id(ag.coalition) and ag.living for ag in self.agents)1416        # explore_decay = max(0.1, 1 - self.step_count / (0.5 * self.max_steps))1417        # 已发现任务比例用于衰减探索奖励,前期鼓励发现,后期避免一直探索。1418        discovered_ratio = float([task.discovered and task.type == TaskType.Deliver for task in self.tasks].count(True)) / self.delivery_task_num1419        ep_check_1 = 2001420        ep_check_2 = 6001421        # if self.episode < ep_check_1:1422        #     ep_decay = 0.01423        # elif self.episode < ep_check_2:1424        #     ep_decay = float(self.episode - ep_check_1) / (ep_check_2 - ep_check_1)1425        # else:1426        #     ep_decay = 1.01427        ep_decay = 1.014281429        step_decay = self.step_count / self.max_steps1430        # rewards_1/rewards_2 是历史上两套奖励方案的混合接口;当前 ep_decay=1,只使用 rewards_2。1431        rewards = [{key: 0.0 for key in self.reward_keys} for _ in range(self.agent_num)]1432        rewards_1 = [{key: 0.0 for key in self.reward_keys} for _ in range(self.agent_num)]1433        rewards_2 = [{key: 0.0 for key in self.reward_keys} for _ in range(self.agent_num)]143414351436        for i, ag in enumerate(self.agents):14371438            if not ag.dead and not ag.living:1439                ag.coalition = self.idle_task_id1440                continue14411442            coalition_type = self.tasks[ag.coalition].type1443            last_coalition_type = self.tasks[ag.last_state.coalition].type1444            # 切换联盟,且上回合不空闲,惩罚1445            if ag.coalition_changed and not ag.last_state.idle:1446                # 切换到探索(第一回合不罚)1447                if self.step_count > 0 and coalition_type == TaskType.Explore:1448                    # 从运输、补给切换到探索,罚1449                    if last_coalition_type in [TaskType.Deliver, TaskType.Resupply]:1450                        rewards_2[i]['switch_coalition'] -= 1.014511452                # 从运输或补给切换1453                if last_coalition_type in [TaskType.Deliver, TaskType.Resupply]:1454                    rewards_2[i]['switch_coalition'] -= 1.514551456            if ag.coalition_changed and ag.last_state.idle:1457                # 从空闲状态出发时,用载荷比例决定补给/执行任务是否合理。1458                load_ratio = ag.load / int(ag.init_load)1459                rewards_2[i]['switch_coalition'] += (-1.0 if coalition_type == TaskType.Resupply else 1.0) \1460                                                * np.sinh((load_ratio - 0.5) * 2) / np.sinh(1.0) * 2.01461                # if coalition_type == TaskType.Resupply:1462                    # if load_ratio < 0.4:1463                    #     rewards_2[i]['switch_coalition'] += ((0.4 - load_ratio) ** 2) * 514641465                # else:1466                #     rewards_2[i]['switch_coalition'] += load_ratio - 0.514671468            # 死亡惩罚1469            if ag.dead:1470                rewards_2[i]['dead'] -= 5.014711472            # 探索奖励1473            # 发现事件奖励按当前发现比例衰减;没有任何探索者时额外惩罚,避免全体过早放弃探索。1474            rewards_2[i]['discover'] = ag.discovery * (0.1/(discovered_ratio + 0.1) if discovered_ratio<0.25 else 0.1/0.35)1475            rewards_2[i]['discover'] -= 0.2 if not any_explore and discovered_ratio <= 0.9 else 0.014761477            # 运输奖励1478            # deliver 是本步真实交付量,直接转成稠密奖励。1479            rewards_2[i]['deliver'] = ag.delivery * 0.114801481            # 补给奖励1482            # 补给越充分奖励越高;无效补给会通过 -1 基线体现代价。1483            rewards_2[i]['resupply'] += ag.resupply_ratio * 2.0 - 1.0 if ag.resupplied else 0.014841485            # 联盟运输奖励1486            # rewards_2[i]['coalition_deliver']  = ag.coalition_delivery * 0.0714871488            # 联盟完成任务奖励1489            # rewards_2[i]['coalition_complete'] += ag.coalition_completed * 4.014901491            base = 20.01492            time_coef = 20.01493            # 单任务完成奖励预留变量;当前没有写入分项,只保留计算位置。1494            completion_reward = (base + time_coef * (1.0 - self.step_count / self.max_steps)) if ag.coalition_completed else 014951496            if all_task_completed:1497                # 全部配送任务完成时给全体终局奖励,越早完成奖励越高。1498                rewards_2[i]['coalition_complete'] += (1 - self.step_count / self.max_steps) * 20 + 2014991500            # 距离奖励1501            if not ag.coalition_changed \1502                    and coalition_type != TaskType.Idle \1503                    and coalition_type != TaskType.Explore:1504                unchanged_decay = (ag.unchanged_time if ag.unchanged_time < 5.0 else 5.0) / 5.01505                rewards_2[i]['dist'] = 0.002 * (ag.last_state.dist - ag.dist)15061507            # 时间惩罚1508            # 每步固定惩罚起到时间成本作用,所有 agent 都会承担。1509            rewards_2[i]['dist'] -= 0.215101511            if all_still:1512                # 全体静止通常表示策略陷入空转,额外惩罚。1513                rewards_2[i]['dist'] -= 1.015141515            # 回合结束平均奖励1516            # end 分项按当前总交付量和完成数给所有 agent 共享进度信号。1517            rewards_2[i]['end'] += sent / self.sent_goal * 101518            rewards_2[i]['end'] += completed / self.delivery_task_num * 501519            rewards_2[i]['end'] /= 2.015201521        for i in range(self.agent_num):1522            for key in self.reward_keys:1523                # 当前 ep_decay=1,因此 rewards_1 不影响实际奖励。1524                rewards[i][key] = (1 - ep_decay) * rewards_1[i][key] + ep_decay * rewards_2[i][key]15251526        rewards_ret = np.zeros(self.agent_num, dtype=float)1527        rewards_info = np.zeros((len(self.reward_keys), self.agent_num), dtype=float)15281529        for i, rewards_ag in enumerate(rewards):1530            for key, rw in rewards_ag.items():1531                # rewards_ret 给 PPO 使用;rewards_info 保留每个分项,供日志和评估分析。1532                rewards_ret[i] += rw1533                rewards_info[self.reward_keys.index(key)][i] = rw15341535        rewards_ret = rewards_ret[..., None]15361537        # rewards_info = np.concatenate([rewards_info, np.array([ag.coalition for ag in self.agents]).reshape(1, -1)])15381539        return rewards_ret, rewards_info15401541    def _get_coalition_delivery_rewards(self, sent, completed, new_discovered, all_task_completed, all_still):1542        """coalition_delivery 场景的基础奖励版本。15431544        强调配送进度、按时完成、缺货补给和未完成任务的终局惩罚。1545        """15461547        rewards = [{key: 0.0 for key in self.reward_keys} for _ in range(self.agent_num)]1548        discovered_ratio = float(1549            [task.discovered and task.type == TaskType.Deliver for task in self.tasks].count(True)1550        ) / self.delivery_task_num1551        any_explore = any(self._is_explore_task_id(ag.coalition) and ag.living for ag in self.agents)1552        remaining_demand = sum(1553            max(0.0, float(task.demand))1554            for task in self.tasks1555            if task.type == TaskType.Deliver and not task.completed1556        )1557        unfinished_tasks = sum(1558            11559            for task in self.tasks1560            if task.type == TaskType.Deliver and not task.completed1561        )1562        has_remaining_delivery = remaining_demand > 1e-615631564        for i, ag in enumerate(self.agents):1565            if not ag.dead and not ag.living:1566                ag.coalition = self.idle_task_id1567                continue15681569            task = self.tasks[ag.coalition]1570            coalition_type = task.type1571            last_coalition_type = self.tasks[ag.last_state.coalition].type15721573            if ag.coalition_changed and not ag.last_state.idle:1574                rewards[i]["switch_coalition"] -= 0.051575                if last_coalition_type == TaskType.Deliver and coalition_type != TaskType.Deliver:1576                    rewards[i]["switch_coalition"] -= 0.0515771578            if ag.dead:1579                rewards[i]["dead"] -= 5.015801581            rewards[i]["discover"] += ag.discovery * 1.01582            if not any_explore and discovered_ratio < 0.9:1583                rewards[i]["discover"] -= 0.0515841585            if ag.delivery > 0:1586                rewards[i]["deliver"] += 0.05 * ag.delivery1587            elif ag.delivery < 0:1588                rewards[i]["deliver"] -= 0.515891590            if ag.resupplied:1591                rewards[i]["resupply"] += 0.8 * ag.resupply_ratio15921593            if ag.coalition_completed:1594                completed_task = self.tasks[ag.coalition]1595                priority = float(completed_task.req_vec[0]) if completed_task.req_vec is not None else 1.01596                deadline_step = float(completed_task.urgency) * self.max_steps1597                on_time_bonus = max(0.0, (deadline_step - self.step_count) / max(1.0, self.max_steps))1598                rewards[i]["coalition_complete"] += 10.0 * priority + 10.0 * on_time_bonus15991600            if has_remaining_delivery:1601                rewards[i]["dist"] -= 0.0051602                if coalition_type == TaskType.Idle:1603                    rewards[i]["dist"] -= 0.031604                if coalition_type == TaskType.Explore and discovered_ratio >= 0.99:1605                    rewards[i]["discover"] -= 0.051606                if ag.load <= 0 and coalition_type != TaskType.Resupply:1607                    rewards[i]["dist"] -= 0.041608            if coalition_type != TaskType.Idle:1609                rewards[i]["dist"] -= 0.0151610            if coalition_type in (TaskType.Deliver, TaskType.Resupply):1611                rewards[i]["dist"] += 0.001 * (ag.last_state.dist - ag.dist)1612            if all_still:1613                rewards[i]["dist"] -= 0.216141615        if self.step_count >= self.max_steps or all_task_completed:1616            shortage_penalty = (1617                12.0 * remaining_demand / max(1.0, float(self.sent_goal))1618                + 4.0 * unfinished_tasks / max(1.0, float(self.delivery_task_num))1619            )1620            completion_bonus = 15.0 if all_task_completed else 0.01621            for item in rewards:1622                item["end"] += completion_bonus - shortage_penalty16231624        rewards_ret = np.zeros(self.agent_num, dtype=float)1625        rewards_info = np.zeros((len(self.reward_keys), self.agent_num), dtype=float)1626        for i, rewards_ag in enumerate(rewards):1627            for key, rw in rewards_ag.items():1628                rewards_ret[i] += rw1629                rewards_info[self.reward_keys.index(key)][i] = rw1630        return rewards_ret[..., None], rewards_info16311632    def _get_coalition_delivery_rewards_shaped(self, sent, completed, new_discovered, all_task_completed, all_still):1633        """coalition_delivery 场景的 shaped 奖励版本。16341635        在基础版本上加强全局配送进度、剩余 backlog 和 deadline pressure 的连续塑形。1636        """16371638        rewards = [{key: 0.0 for key in self.reward_keys} for _ in range(self.agent_num)]1639        delivery_tasks = [task for task in self.tasks if task.type == TaskType.Deliver]1640        discovered_ratio = float(1641            [task.discovered for task in delivery_tasks].count(True)1642        ) / max(1, self.delivery_task_num)1643        any_explore = any(self._is_explore_task_id(ag.coalition) and ag.living for ag in self.agents)1644        remaining_demand = sum(1645            max(0.0, float(task.demand))1646            for task in delivery_tasks1647            if not task.completed1648        )1649        unfinished_tasks = sum(1 for task in delivery_tasks if not task.completed)1650        has_remaining_delivery = remaining_demand > 1e-61651        time_ratio = self.step_count / max(1.0, float(self.max_steps))1652        sent_goal = max(1.0, float(self.sent_goal))16531654        progress_reward = 25.0 * float(sent) / sent_goal1655        backlog_cost = 0.025 * (remaining_demand / sent_goal) * (0.5 + time_ratio)1656        urgency_pressure = 0.01657        for task in delivery_tasks:1658            if task.completed:1659                continue1660            remaining_ratio = max(0.0, float(task.demand)) / max(1.0, float(task.initial_demand))1661            priority = float(task.req_vec[0]) if task.req_vec is not None else 1.01662            deadline_overrun = max(0.0, time_ratio - float(task.urgency))1663            urgency_pressure += priority * remaining_ratio * (0.25 * time_ratio + deadline_overrun)1664        urgency_cost = 0.02 * urgency_pressure / max(1, self.delivery_task_num)16651666        for i, ag in enumerate(self.agents):1667            if not ag.dead and not ag.living:1668                ag.coalition = self.idle_task_id1669                continue16701671            task = self.tasks[ag.coalition]1672            coalition_type = task.type1673            last_coalition_type = self.tasks[ag.last_state.coalition].type16741675            if ag.coalition_changed and not ag.last_state.idle:1676                rewards[i]["switch_coalition"] -= 0.051677                if last_coalition_type == TaskType.Deliver and coalition_type != TaskType.Deliver:1678                    rewards[i]["switch_coalition"] -= 0.0516791680            if ag.dead:1681                rewards[i]["dead"] -= 5.016821683            rewards[i]["discover"] += ag.discovery * 1.01684            if not any_explore and discovered_ratio < 0.9:1685                rewards[i]["discover"] -= 0.051686            if has_remaining_delivery and coalition_type == TaskType.Explore and discovered_ratio >= 0.99:1687                rewards[i]["discover"] -= 0.0816881689            if ag.delivery > 0:1690                rewards[i]["deliver"] += 0.04 * ag.delivery1691            elif ag.delivery < 0:1692                rewards[i]["deliver"] -= 1.016931694            if ag.resupplied:1695                resource_gain = float(ag.resupply_ratio)1696                rewards[i]["resupply"] += 1.0 * resource_gain1697                if resource_gain <= 0.05:1698                    rewards[i]["resupply"] -= 0.5 * (1.0 - resource_gain / 0.05)16991700            if ag.coalition_completed:1701                completed_task = self.tasks[ag.coalition]1702                priority = float(completed_task.req_vec[0]) if completed_task.req_vec is not None else 1.01703                deadline_step = float(completed_task.urgency) * self.max_steps1704                on_time_bonus = max(0.0, (deadline_step - self.step_count) / max(1.0, self.max_steps))1705                rewards[i]["coalition_complete"] += 8.0 * priority + 8.0 * on_time_bonus17061707            if progress_reward != 0.0:1708                rewards[i]["coalition_deliver"] += progress_reward17091710            if has_remaining_delivery:1711                rewards[i]["dist"] -= backlog_cost + urgency_cost1712                if coalition_type == TaskType.Idle:1713                    rewards[i]["dist"] -= 0.041714                if ag.load <= 0 and coalition_type != TaskType.Resupply:1715                    rewards[i]["dist"] -= 0.041716            if coalition_type != TaskType.Idle:1717                rewards[i]["dist"] -= 0.021718            if coalition_type in (TaskType.Deliver, TaskType.Resupply):1719                rewards[i]["dist"] += 0.001 * (ag.last_state.dist - ag.dist)1720            if all_still:1721                rewards[i]["dist"] -= 0.217221723        if self.step_count >= self.max_steps or all_task_completed:1724            shortage_penalty = (1725                14.0 * remaining_demand / sent_goal1726                + 4.0 * unfinished_tasks / max(1.0, float(self.delivery_task_num))1727            )1728            completion_bonus = 15.0 if all_task_completed else 0.01729            for item in rewards:1730                item["end"] += completion_bonus - shortage_penalty17311732        rewards_ret = np.zeros(self.agent_num, dtype=float)1733        rewards_info = np.zeros((len(self.reward_keys), self.agent_num), dtype=float)1734        for i, rewards_ag in enumerate(rewards):1735            for key, rw in rewards_ag.items():1736                rewards_ret[i] += rw1737                rewards_info[self.reward_keys.index(key)][i] = rw1738        return rewards_ret[..., None], rewards_info