深度强化学习在多星区域目标观测规划中的应用与工程落地
简介:面向航天任务规划与深度强化学习研究者的一份实战工程包,聚焦多星对区域目标的观测调度优化问题。资料将深度神经网络的特征表达与强化学习的序贯决策结合,在动态卫星环境下训练智能体选择观测卫星、调整轨道策略,从而提升区域覆盖效率,适合希望将DRL落地到实际调度场景的开发者或相关专业学生参考。
压缩包内共492个文件,容量约198.33MB,包含Python训练与评估脚本(.py)、仿真环境与轨道数据(.npz、.fits、.csv)、模型权重与训练记录(.pth、.torch、.h5)等,还附带.ipynb示例与实验图表,可支撑从环境模拟、训练调参到结果分析的全流程复现。目前已有193人学习下载,适合具备一定Python与强化学习基础、希望深入理解多星调度建模的读者使用。
用户可从中获得完整的工程目录结构、环境模拟器实现、DQN等深度强化学习算法代码、经验回放与策略网络设计,以及多种卫星数据的预处理与可视化思路,为扩展更复杂的星座观测任务提供可直接改写的基线方案。
1. 为什么区域目标观测规划会让传统任务调度器集体翻车
多星对区域目标观测规划做久了你会发现,第一版“按收益排序贪心”的调度器一定会在第 3 天翻车:条带之间互相抢机动时间,存储和侧摆角消耗在整段弧段里连成一片,覆盖率卡在 70% 就不再动。深度强化学习算法把这套调度当成序贯决策,每颗卫星只看自己的局部状态,靠奖励信号学会“什么时刻该抢、什么时刻该放弃”,比手工写规则灵活得多。不管是尝试做原型验证的算法工程师,还是做运控系统改造的技术负责人,都需要先把场景缩到“几十颗星+一块区域”再谈落地。这篇文章就是按“建模→选型→最小实现→排错→部署验证”的顺序,把能直接照抄的部分拆开写。
2. 把区域目标拆成可学习的 MDP:条带划分、可见时间窗与状态动作设计
区域目标不是点目标,卫星也不是拍照就能覆盖一整块的相机。载荷沿星下点轨迹扫过地面,形成的是带状的幅宽,所以一切基于强化学习的规划都要先回答一个问题:定义里的“观测对象”到底是什么。我一般会先按卫星载荷幅宽把区域切条带,再把条带中心点当成可访问目标来计算时间窗。别一上来就做网格栅格化,那会让动作空间膨胀到连 PPO 都救不回来。
2.1 条带划分:为什么按沿轨方向切,而不是按经纬度网格切
按经纬度网格切看似直观,但实际上每颗星的轨迹方向不同,同一个网格点在不同轨道上的侧摆角差异会很大。按轨迹方向切条带的好处是:单条带只经过一颗星的某一段过境弧段,姿态机动代价容易计算,覆盖收益也不容易被两次观测重复夸大。条带宽度通常取载荷幅宽本身,需要重叠余量时再加 10% 到 15%,但这是你唯一要调的几何参数。
这里给出一个用 numpy 实现的简化条带划分函数,核心思路是把多边形顶点投影到垂直航迹方向,再按幅宽分段。
import numpy as np def split_area_into_strips(polygon_xy, track_direction_deg, strip_width_km): """把区域多边形拆成沿轨方向的条带。 polygon_xy: N*2 的顶点经纬度数组 track_direction_deg: 星下点轨迹方向角,0 表示沿经线方向 strip_width_km: 单条带幅宽,按载荷幅宽的 100% 取值 """ track_rad = np.deg2rad(track_direction_deg) along = np.array([np.cos(track_rad), np.sin(track_rad)]) perp = np.array([-along[1], along[0]]) proj = polygon_xy @ perp lo, hi = proj.min(), proj.max() # 1 度纬度约 111 km;经度方向要除以 cos(纬度),这里用近似值 span_km = (hi - lo) * 111.0 n_strips = max(1, int(np.ceil(span_km / strip_width_km))) strips = [] for i in range(n_strips): c = lo + (i + 0.5) * (hi - lo) / n_strips idx = np.argmin(np.abs(proj - c)) strips.append(polygon_xy[idx]) return np.asarray(strips), n_strips area = np.array([[100.5, 36.0], [102.0, 36.2], [102.1, 37.5], [100.8, 37.5]]) strips, n = split_area_into_strips(area, track_direction_deg=65.0, strip_width_km=50.0) print("拆出条带数:", n, "中心点示例:", strips[0])这段代码里,track_direction_deg不是随便填的,它来自星下点轨迹在地面投影的方向。惯性系里卫星轨道的倾角和升交点赤经确定后,地面轨迹方向是周期性变化的,仿真时要按过境时刻的瞬时轨迹方向传入。条带宽度如果取一半幅宽,重叠率会很高,训练时覆盖收益虚高,但真实载荷并没有冗余这一说;所以默认取幅宽,后续在奖励函数里用“新增覆盖面积”去抑制重复观测。
2.2 可见时间窗:把侧摆角和姿态机动时间一起折进去
条带中心有了之后,下一步是算出每颗星对每个条带的可见时间窗。工程上最省事的做法是使用 SGP4 轨道预报,得到每个时刻的星下点位置,再判断星下点与条带中心的地心角是否小于允许上限。允许地心角由最大侧摆角和轨道高度共同决定。无代码也能做:预报精度在分钟级即可,因为真正的姿态机动时间会吃掉秒级误差。
一个常见失误是把“可见”定义成“侧摆角不超限”,却忘了机动时间。卫星从当前条带的观测姿态转到目标条带,需要以 0.5°/s、1°/s 这样的角速度做过境准备,转过 30° 就是要 30 秒。在低轨场景里,这段时长足以让目标飞出窄幅宽的条带。所以我会把时间窗两端各收缩一个机动时间,得到“可用时间窗”,训练时只考虑可用时间窗;宁可少一批样本,也不要环境里出现“看到但来不及拍”的假可行动作。
2.3 状态、动作与奖励:让智能体知道该抢哪条带
状态设计不要太复杂。单星的局部状态我通常固定为:归一化剩余可观测条带数、每条带剩余待覆盖比例、自身存储占用率、当前侧摆角、最近三个可用时间窗的剩余时间。如果需要多星协作,再追加一个全局覆盖矩阵的压缩向量,尺寸做成固定长度,不要用变长数组进网络。
动作空间推荐离散型:0 表示空闲等待,1 到 N 表示选第 N 条带观测。多星环境下动作空间共享,某些条带不可见时用动作掩码挡掉。奖励函数采用增量覆盖和新覆盖比例,设惩罚项:
| 奖励/惩罚项 | 定义 | 权重建议 |
|---|---|---|
| 覆盖收益 | 本步新增覆盖面积 / 目标总面积 | 1.0 |
| 空闲惩罚 | 有可用窗口但选择等待 | -0.1 |
| 机动惩罚 | 侧摆角变化绝对值归一化 | -0.02 |
| 存储惩罚 | 存储超限或接近峰值 | -0.5 |
奖励里最重要的不是绝对值,而是“增量”二字。如果按总覆盖率给奖励,多颗星都会抢同一个高价值条带,因为任何人观测都能得到一样的总收益,竞争信号会被掩盖。
3. 深度强化学习算法选型与网络设计:MAPPO 路线、CTDE 框架和训练参数怎么取舍
算法选型这件事,很多人一上来就上 MADDPG,做完一版发现动作分支在离散选择上要反复调 Gumbel-Softmax,训练方差大。我的对比结论是:多星对区域目标观测规划,首选 MAPPO。原因是动作本质是离散选择,协作信号是稀疏的覆盖率变化,而 MAPPO 作为多智能体 PPO,稳定性和超参敏感度都更适合工程落地。
3.1 为什么用 MAPPO 而不是 DQN、MADDPG 或独立 PPO
DQN 适合单智能体、动作空间小且目标固定的场景,区域目标规划里每颗星的可用窗口随时间变化,目标数量也跨批次变动,Q 值更新很容易振荡。MADDPG 适合连续动作,比如“把侧摆角调到某个连续角度”,但这里动作是选哪条带,用连续动作再映射回离散空间,模拟的是没有必要的中间层。
对比之下 MAPPO 的核心思路是:每一个智能体有自己的 Actor 策略,共享一个中央 Critic。Centralized Training,Decentralized Execution,训练时可以看全局信息,部署时只保留局部 Actor。对卫星场景特别合适,因为卫星彼此通信受限,推理必须在星上独立完成。
3.2 Actor-Critic 网络结构:局部观测进 Actor,全局观测进 Critic
用 PyTorch 搭一个最小框架并不难。Actor 输入是单星局部观测obs_dim,输出各条带动作 logits;Critic 输入是所有星的局部观测拼成的全局向量,输出一个价值标量。
import torch import torch.nn as nn class LocalActor(nn.Module): def __init__(self, obs_dim, n_actions, hidden=256): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, n_actions), ) def forward(self, obs, action_mask=None): logits = self.net(obs) if action_mask is not None: # 不可见条带直接屏蔽,不能让模型去学一个负无穷 logits 的物理含义 logits = logits.masked_fill(~action_mask, -1e8) return logits class CentralCritic(nn.Module): def __init__(self, global_dim, hidden=256): super().__init__() self.net = nn.Sequential( nn.Linear(global_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), ) def forward(self, global_obs): return self.net(global_obs)这里的全局观测只在训练阶段参与,部署时整个 Critic 都可以丢掉。训练时有一个数据处理的细节:多星环境里每颗星观测窗口不同,拼接全局向量前要先按固定顺序排序,否则同一批数据里星号顺序一变,Critic 的输出就失去意义;我一般会把所有星按轨道编号排序后拼接。
3.3 PPO 训练参数:先看覆盖率,再看 loss
MAPPO 的训练参数和 PPO 基本一致,但多智能体场景要把 entropy coefficient 调大一点,防止多颗星的策略过早同时锁死到同一条带。经验值如下:
| 参数 | 建议区间 | 说明 |
|---|---|---|
| learning_rate | 2e-4 ~ 5e-4 | 从 3e-4 起步 |
| n_steps | 2048 ~ 4096 | 过短导致方差大 |
| batch_size | 256 ~ 1024 | 显存允许时取大 |
| clip_range | 0.15 ~ 0.25 | 默认 0.2 |
| gae_lambda | 0.95 ~ 0.99 | 偏 0.98 |
| ent_coef | 0.01 ~ 0.05 | 多星场景别低于 0.01 |
训练时画面很玄学,loss 曲线不一定平滑,真正要看的是“评价仿真器里的覆盖率”和“动作熵”。动作熵一直掉到接近 0,说明策略已经确定化;如果覆盖率还停留在低位,基本是奖励函数设计问题,不是优化器问题。这时候优先检查奖励的稀疏程度,而不是去改网络层数。
4. 最小可复现实现:用 Gym 风格环境跑通单星区域目标观测调度
要把问题缩到能在一台机器上跑通,不要一上来就模拟完整的六十颗星。我通常先做单星环境,把条带、时间窗、机动模型做完整,用 MAPPO 跑通覆盖率上涨的曲线,再平移到多星。单星环境的意义不只是调试,它能把“状态动作定义是否合理”这个问题和“多智能体协作”切成两半。
4.1 环境类的最小定义:状态转移与奖励计算
环境每走一步代表一个离散时间片,时间片长度的选择直接影响收敛速度。时间片太短,机动还没完成,环境就要等到机动结束才能观测,空转步骤多;时间片太长,目标快速飞出窗口。我一般把时间片设为 30 秒,和姿态机动时间量级一致。
import numpy as np class RegionObsEnv: """单星区域目标观测环境,动作 0 表示空闲,1..N 表示观测对应条带。""" def __init__(self, strips, twindows, max_storage=100.0, min_interval=2, max_slew_rate=1.0): self.strips = strips # 条带中心点列表 self.twindows = twindows # [(start, end), ...],单位:时间片序号 self.max_storage = max_storage self.min_interval = min_interval # 连续观测必须间隔的步数 self.max_slew_rate = max_slew_rate self.n_strips = len(strips) self.time_idx = 0 self.storage = 0.0 self.slew_angle = 0.0 self.covered = np.zeros(self.n_strips) def step(self, action): reward = 0.0 reset_flag = False if action == 0: reward -= 0.1 # 空闲惩罚 else: sat_idx = action - 1 start, end = self.twindows[sat_idx] if start <= self.time_idx <= end: # 模拟机动耗时:角度差越大,越可能错过窗口 angle_cost = abs(self.slew_angle - sat_idx * 10.0) if self.time_idx + angle_cost * 0.1 <= end: gain = self.covered[sat_idx] * 0.0 if self.covered[sat_idx] < 1.0: gain = 1.0 - self.covered[sat_idx] reward += gain self.covered[sat_idx] = 1.0 self.storage += 2.0 self.slew_angle = sat_idx * 10.0 if self.storage > self.max_storage: reward -= 0.5 reset_flag = True self.time_idx += 1 done = reset_flag or self.time_idx >= 480 return self._build_obs(), reward, done, {} def _build_obs(self): # 局部观测:剩余时间窗、覆盖状况、存储、侧摆角 return np.array([ self.time_idx / 480.0, self.covered.mean(), self.storage / self.max_storage, self.slew_angle / 50.0, ], dtype=np.float32)这个环境故意把姿态角和存储做了简化,但保留了两个最容易让新人翻车的机制:空闲惩罚和机动时间消耗。angle_cost的存在让智能体不能在同一时间片内连续观测两个大角度差条带,否则会错过窗口。真实仿真里这种耦合会更复杂,但最小实现里只要你能看到覆盖率从 30% 爬到 80%,就说明状态动作定义是自洽的。
4.2 训练主循环:MAPPO 的最小调用方式
单星环境可以直接用 PPO 算法训练,不必先上多智能体框架。常见做法是借助 stable-baselines3 的 PPO 实现,库内部已经处理了 GAE、clip 和 mini-batch,你要做的只是把环境包进去。
from stable_baselines3 import PPO env = RegionObsEnv( strips=strips, twindows=windows, max_storage=100.0, min_interval=2, max_slew_rate=1.0, ) model = PPO( "MlpPolicy", env, learning_rate=3e-4, n_steps=2048, batch_size=256, ent_coef=0.02, clip_range=0.2, gamma=0.99, verbose=0, ) model.learn(total_timesteps=800_000) model.save("./region_obs_policy.zip")n_steps=2048表示每轮收集 2048 步环境交互,batch_size=256是每一轮内更新时的样本子块大小。gamma 0.99 对分钟级时间片来说比较充裕,如果时间片加长到 2 分钟,建议降到 0.95,否则远期奖励折损过快。ent_coef=0.02是单星环境比较稳妥的探索量,训练到后期如果发现策略反复震荡,可以降到 0.01。
保存权重时直接用了.zip后缀,这是算法库默认的序列化格式。但注意这只存了网络权重,没有存环境定义和轨道数据,所以同时还要把归一化统计量、轨道预报结果和条带划分参数一起写入另一个文件。否则模型换了条带数量就完全没法推理。
4.3 评估指标:覆盖率高不等于策略好
训练过程中的 reward 曲线只能帮你判断是否收敛,不能用来向用户证明方案可用。我会单独写一个评估函数,把一整段弧段拆成步进,计算四个核心指标:覆盖率、失败动作率、总机动角度和存储峰值。其中失败动作率特别值得看,它统计的是智能体发起了观测但条带已经不可见的次数。
覆盖率可以到 90%,但如果失败动作率高,说明策略在依赖某种“侥幸命中”的模式,真实部署时批次一旦改变就会崩。评估时还会对比贪心基线:每步选可见窗口里剩余时间最短的条带,这个基线实现简单,如果 DRL 策略连它都打不过,那问题出在奖励或状态定义上,不要怀疑算法本身。
5. 多星观测规划落地避坑:训练不收敛与策略“微调翻车”的 5 个排错点
前面章节讲的都是“怎么做对”,这一章讲的是我实际踩过、也看同事反复踩过的坑。多星场景和单星最大的区别在于,单个智能体的奖励信号和多智能体之间的协作耦合在一起,出问题时定位难度成倍上升。
5.1 奖励不收敛:几十万步之后覆盖率还是贴地
现象:训练了 60 万步,覆盖率曲线像一条水平直线,reward 偶尔跳高但很快又掉回低位。原因多半是奖励稀疏加量纲失衡。覆盖率 0 到 1 的增量,和 -0.5 的存储惩罚混在一起时,存储惩罚可能把正常覆盖收益全部抵消,策略学到“什么都不做最安全”。
解决:把奖励各项拆开分别归一化。覆盖收益控制在 0~1,存储惩罚设成条件触发而不是持续输出,空闲惩罚也不要和覆盖收益直接相加。先单星跑通一个无存储、无机动约束的“理想环境”,确认覆盖率能涨到上限,再逐步加入约束。这叫增量式环境复杂度,是排除奖励问题的标准手段。
5.2 “假覆盖率”与时间步错位:观测收益看着涨,真实调度一测就崩
现象:仿真里覆盖率到达 92%,但把同样的策略接到更精细的调度仿真器里,覆盖率掉到 60%。原因不是策略没学到,而是环境的时间推进方式太粗糙。离散时间片内假设姿态机动瞬时完成,导致收益被提前计入,真实卫星还在摆姿态。
解决:把“动作发出”和“观测开始”拆成两个事件。动作发出后,先计算机动结束时刻,只有机动结束时刻落在可见窗内,才允许获得覆盖收益。这个修正会让训练变慢,但评估结果才有参考价值。凡是环境里敢对机动时间做简化,就能在网上找到一堆“训练很好、实测翻车”的血泪经验。
5.3 多颗星集体锁死同一条带:全局奖励的“搭便车”效应
现象:多星训练后,覆盖率曲线正常,但拆开每颗星的动作统计发现,60% 的动作集中在同一条高价值条带,其他条带覆盖率靠运气。原因在于奖励用的是全局新增覆盖,每颗星从同一份奖励中取梯度,高价值动作被反复放大。
解决:先用“独热覆盖矩阵”做状态输入,奖励改为该星本次实际新增覆盖面积,而不是全局覆盖率增量。如果还想进一步提升协作性,再考虑 QMIX 或带有反事实基线的 MAPPO。工程上别一上来就上复杂价值分解,优先把“收益归因”改成“贡献归因”。
5.4 换一个区域目标就崩:训练数据域太窄
现象:在固定区域 A 上训练 80 万步,测试区域 B 时覆盖率骤降。原因很直接:仿真只用了固定的区域多边形、固定的条带数和固定的可见时间窗。神经网络记住了这些固定参数,没有学到可泛化的“观测决策”。
解决:训练过程域随机化。每个 episode 开始前,把区域多边形顶点做小幅随机移动,条带宽度在 45~60 km 之间随机,时间窗长度按轨道预报加随机偏移。这是深度强化学习算法落地规划问题里性价比最高的代码改动,比调网络结构更有效。
5.5 版本与权重打包:复现失败多发生在“zip 包不完整”而非算法
现象:训练成果存档后,一个月后重新加载报invalid zip archive: could not find eocd,整个实验记录报废。原因常是权重文件与配置数据分开保存,归档时漏了环境版本或轨道数据,或者分包传输导致文件截断。
解决:把实验目录当作交付物看待,一条训练记录至少包含:策略权重、环境源码、轨道预报输入、条带划分参数、随机种子、评估日志。一起打包成一个 zip,并用校验值记录完整性。我不会在训练完成后立刻删中间模型,而是保留最后一个 checkpoint 和训练日志,因为奖励曲线图往往是定位问题最有用的“后悔药”。
6. 把策略接入滚动时窗:一个轻量验证仿真器和“查表部署”技巧
训练完成后不能直接把模型文件丢给运控系统。多星对区域目标观测规划的现实约束是:每天都有新的目标区域上报,轨道根数也在持续更新。常见做法是把训练好的策略冻结为一个“候选动作生成器”,放进滚动时窗里反复调用:每次目标区域更新后,重新计算条带与可见时间窗,然后让策略输出一张“时间-动作-卫星”的决策表,再由运控人员按优先级和硬约束做最后检查。
验证这一步不需要重型系统,一个轻量回放器就够了。它的输入是拍平的状态序列,输出是策略动作序列,再回放到细化仿真里对比覆盖率。
def plan_with_policy(policy, windows, state_factory): plan = [] for t in range(windows.shape[0]): obs = state_factory.build(windows, t) available_mask = state_factory.visible_mask(t) action, _ = policy.predict(obs, mask=available_mask) plan.append((t, int(action))) return plan这段代码里的policy.predict在部署时只加载 Actor 网络,不加载 Critic,因此推理内存占用通常在 10 MB 以内,满足星载约束。每次生成计划后,我会跑一遍回放,确认覆盖率不低于上一次计划,已规划的观测动作不重叠,存储和能量曲线不越界。这一步相当于给策略上了保险。
我习惯在归档时把这条命令的输入输出都列在实验目录里:哪些是训练过的目标区域,哪些是全新的评估区域。换一个区域跑不过,先看训练域随机化的范围是否覆盖了新区域,而不是直接调模型。这个方向真正值得投入的地方,不在模型结构有多新颖,而在训练环境和真实运控约束对齐了多少。希望帮到你。
本文还有配套的精品资源,点击获取