强化学习第一版

This commit is contained in:
2026-09-11 16:56:39 +08:00
parent 2bc4c7ed82
commit 4699b0d45f
78 changed files with 27978 additions and 706 deletions
+33 -6
View File
@@ -30,6 +30,7 @@ from .protocol.log import SessionLog
from .protocol.rules import EXIT_USER
Q3_STRATEGIES = {"grid": OmnidirectionalLoop, "seven": SevenBatch, "bayes": BayesLoop}
Q4_STRATEGIES = {"directional": DirectionalExtension}
def _load_referee():
@@ -251,16 +252,33 @@ def _run(args) -> int:
log = SessionLog(path=Path(args.log)) if args.log else SessionLog()
client = SimulatorClient(args.robot_id, config=config, transport=transport, log=log)
if args.problem == 4:
loop_cls = DirectionalExtension
strategy = args.q4_strategy
loop_cls = Q4_STRATEGIES.get(strategy)
else:
loop_cls = Q3_STRATEGIES[args.strategy]
strategy = args.strategy
loop_cls = Q3_STRATEGIES.get(strategy)
try:
summary = loop_cls(client, config).run()
if strategy == "rl":
# 惰性导入:默认运行闭环不加载 numpy/torch/stable-baselines3。
from .planner.rl_loop import RLBayesLoop, RLDirectionalLoop
loop_cls = RLBayesLoop if args.problem == 3 else RLDirectionalLoop
if loop_cls is None:
raise RuntimeError(f"未知策略: {strategy}")
if strategy == "rl":
summary = loop_cls(client, config, model_path=args.rl_model).run()
else:
summary = loop_cls(client, config).run()
except RuntimeError as exc:
print(f"RL 策略不可用: {exc}", file=sys.stderr)
return 2
finally:
if judge is not None:
_settle_local_judge(judge)
summary["robot_id"] = args.robot_id
summary["strategy"] = ("directional" if args.problem == 4 else args.strategy)
if args.problem == 4:
summary["strategy"] = "rl_q4" if strategy == "rl" else strategy
else:
summary["strategy"] = strategy
if real_budget_s is not None:
summary["self_test_real_budget_s"] = real_budget_s
if args.summary_steps is not None and len(summary.get("steps", [])) > args.summary_steps:
@@ -305,10 +323,19 @@ def main(argv=None) -> int:
run.add_argument("--log", default=None, help="会话日志 JSONL 输出路径")
run.add_argument("--problem", type=int, choices=(3, 4), default=3,
help="问题3=全向源,问题4=混合定向源(默认 3)")
run.add_argument("--strategy", choices=tuple(Q3_STRATEGIES), default="bayes",
run.add_argument("--strategy", choices=tuple(Q3_STRATEGIES) + ("rl",),
default="bayes",
help="问题3策略:bayes=粒子滤波+信息增益+滚动调度(默认,"
"自建消融相对 seven 再降 19.5%%),seven=七点批量,"
"grid=49点网格基线;仅 --problem 3 生效")
"grid=49点网格基线,rl=PPO 强化学习选点(需先训练模型);"
"仅 --problem 3 生效")
run.add_argument("--q4-strategy", choices=tuple(Q4_STRATEGIES) + ("rl",),
default="directional",
help="问题4策略:directional=49点覆盖+遮挡梯子+后备清除(默认),"
"rl=PPO 强化学习选点(需先训练模型);仅 --problem 4 生效")
run.add_argument("--rl-model", default=None,
help="RL 模型路径(默认 output/rl/q3_ppo.zip 或 "
"output/rl/q4_ppo.zip;训练脚本 scripts/train_rl.py)")
run.add_argument("--bearing-margin-deg", type=float, default=0.005,
help="示向度舍入裕量(服务器保留两位小数;默认 0.005°)")
run.add_argument("--real-budget-s", type=float, default=None,
+92
View File
@@ -0,0 +1,92 @@
"""P6b 强化学习闭环接入:PPO 学习"下一检测点"选择(问题3/4)。
边界与保守性声明:
- RL 只替换测量点排序(在既有几何候选集内做 argmax),不生成新的
空间候选、不修改覆盖证明、不修改清除证书;
- 问题3:继承 :class:`BayesLoop`(七点覆盖 + 粒子滤波 + 滚动任务调度),
仅 :meth:`_select_point` 改由 PPO 输出候选编号;
- 问题4:继承 :class:`DirectionalExtension`(49 点定向覆盖 + 对跖/环探 +
20m 有限圆盘覆盖后备清除),仅 :meth:`_next_probe` 改由 PPO 输出;
遮挡计数、定向观测与后备清除兜底完全保留;
- 训练环境与运行特征同源(mcm26b.rl.features),模型加载时校验
观测/动作维度,不匹配即拒绝运行,避免静默错位。
默认模型位置:output/rl/q3_ppo.zip、output/rl/q4_ppo.zip。
"""
from __future__ import annotations
from pathlib import Path
from ..config import ProblemConfig, Vec2
from ..rl.policy import PointSelectionPolicy, default_model_path
from . import DirectionalExtension
from .bayes_loop import BayesLoop
class RLBayesLoop(BayesLoop):
"""问题3:贝叶斯闭环骨架 + PPO 定位选点(--strategy rl)。"""
def __init__(self, client, config: ProblemConfig = ProblemConfig(), *,
model_path: str | Path | None = None, device: str = "auto"):
super().__init__(client, config)
self.rl_model_path = Path(model_path) if model_path else default_model_path(3)
self.rl_policy = PointSelectionPolicy(self.rl_model_path, device=device)
self.rl_probe_actions: list[dict] = []
def _select_point(self, channel: int) -> Vec2 | None:
region = self.belief.candidate_set(channel)
if region is None:
return None
point, action = self.rl_policy.select(
region, self.client.state.position, channel, self.config,
problem=3, blocked=[], belief=self.belief.channels[channel],
virtual_time_s=self.client.state.virtual_time_s)
self.rl_probe_actions.append({
"channel": channel, "action": int(action),
"position": [round(point[0], 3), round(point[1], 3)],
"virtual_time_s": self.client.state.virtual_time_s,
})
return point
def _summary(self, info) -> dict:
report = super()._summary(info)
report["strategy"] = "rl"
report["rl_model"] = str(self.rl_model_path)
report["rl_device"] = self.rl_policy.device
report["rl_probe_actions"] = self.rl_probe_actions
return report
class RLDirectionalLoop(DirectionalExtension):
"""问题4:定向闭环骨架 + PPO 遮挡梯子选点(--q4-strategy rl)。"""
def __init__(self, client, config: ProblemConfig = ProblemConfig(), *,
model_path: str | Path | None = None, device: str = "auto"):
super().__init__(client, config)
self.rl_model_path = Path(model_path) if model_path else default_model_path(4)
self.rl_policy = PointSelectionPolicy(self.rl_model_path, device=device)
self.rl_probe_actions: list[dict] = []
def _next_probe(self, region, channel: int) -> Vec2:
blocked = self.blocked_probes.get(channel, [])
point, action = self.rl_policy.select(
region, self.client.state.position, channel, self.config,
problem=4, blocked=blocked,
belief=self.belief.channels[channel],
virtual_time_s=self.client.state.virtual_time_s)
self.rl_probe_actions.append({
"channel": channel, "action": int(action),
"position": [round(point[0], 3), round(point[1], 3)],
"blocked_count": len(blocked),
"virtual_time_s": self.client.state.virtual_time_s,
})
return point
def _summary(self, info) -> dict:
report = super()._summary(info)
report["strategy"] = "rl_q4"
report["rl_model"] = str(self.rl_model_path)
report["rl_device"] = self.rl_policy.device
report["rl_probe_actions"] = self.rl_probe_actions
return report
+14
View File
@@ -0,0 +1,14 @@
"""问题3/4 强化学习扩展(可选依赖组 ``rl``)。
- features.py:观测/候选动作的纯几何接口(numpy),训练与运行共享;
- env.py:单源定位 Gymnasium 训练环境(依赖 referee,仅训练侧加载);
- policy.py:PPO 模型加载与确定性推理(惰性加载 torch/sb3)。
运行闭环默认不导入 torch/stable-baselines3;只有 CLI 显式选择
``--strategy rl``(问题3)或 ``--q4-strategy rl``(问题4)时,
``planner.rl_loop`` 才构造 :class:`policy.PointSelectionPolicy`。
"""
from .features import candidate_actions, build_observation
__all__ = ["candidate_actions", "build_observation"]
+11
View File
@@ -0,0 +1,11 @@
"""RL 观测/动作空间常量:模型接口的唯一字面量来源。
布局说明见 features.py;这些数字由训练脚本写入模型接口,
改动其中任一值都会使已训练模型失配,须同步重训。
"""
GLOBAL_DIM = 41
PER_ACTION_DIM = 6
NUM_ACTIONS = 60
LADDER_SLOTS = 8
OBS_DIM = GLOBAL_DIM + NUM_ACTIONS * PER_ACTION_DIM # 401
+308
View File
@@ -0,0 +1,308 @@
"""问题3/4 的强化学习训练环境:单源"测向—定位—清除"子任务。
环境与自建评测机 referee 共享同一边界(内存回环,与真实 HTTP 同协议),
观测只来自 :class:`mcm26b.belief.BeliefState` 与协议响应;真值仅在 reset
时用于布设课程场景,不进入观测。策略端看到的接口与闭环运行时一致。
问题抽象:机器狗已获得某频道首次 direction 读数,需要在一组几何可行候选
检测点中选择下一个测点,目标是尽快把候选集最小包围圆压到 20m 清除证书
阈值并清除成功。问题4额外引入定向源遮挡:保证接收盘内出现 no_signal 是
"朝向遮挡"的观测证据,环境记录 blocked 序列并开放环探候选。
该环境是训练侧组件(依赖 referee + gymnasium),运行时代码不导入本模块;
导入依赖由可选组 ``[dependency-groups] rl`` 提供。
"""
from __future__ import annotations
import math
from random import Random
import gymnasium as gym
import numpy as np
from gymnasium import spaces
from ..belief import BeliefState
from ..config import ProblemConfig
from ..planner import clear_certificate, fallback_clear_cells, safe_disk_radius
from ..protocol.client import SimulatorClient
from ..protocol.log import SessionLog
from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL
from .features import NUM_ACTIONS, OBS_DIM, build_observation, candidate_actions
class LocalizationEnv(gym.Env):
"""单源定位清除 Gymnasium 环境。
problem=3:全向源;problem=4:定向/全向混合(定向概率 0.5,训练分布
比题面更稠密以覆盖遮挡分支;正式策略仍由闭环的后备清除兜底)。
"""
metadata = {"render_modes": []}
def __init__(self, config: ProblemConfig | None = None, *,
problem: int = 3, max_episode_steps: int = 16,
directional_probability: float = 0.5,
real_budget_s: float = 3600.0,
error_seed: int | None = None):
super().__init__()
if problem not in (3, 4):
raise ValueError("problem 必须是 3 或 4")
self.config = config or ProblemConfig()
self.problem = int(problem)
self.max_episode_steps = int(max_episode_steps)
self.directional_probability = float(directional_probability)
self.real_budget_s = float(real_budget_s)
self.error_seed = error_seed
self.observation_space = spaces.Box(low=-10.0, high=10.0,
shape=(OBS_DIM,), dtype=np.float32)
self.action_space = spaces.Discrete(NUM_ACTIONS)
# 惰性导入:本模块只属于训练/评估侧,运行闭环不加载 referee。
from referee import LocalSimulator as _RefereeLocalSimulator
from referee import Source
from ..harness import LocalSimTransport, referee_spec
self._Source = Source
self._LocalSimulator = _RefereeLocalSimulator
self._LocalSimTransport = LocalSimTransport
self._referee_spec = referee_spec
self._scene_rng = Random()
self._simulator = None
self._client = None
self._source = None
self._belief: BeliefState | None = None
self._channel: int = 1
self._blocked: list = []
self._directional_observed = False
self._clear_failed = False
self._steps = 0
self._terminated = False
self._truncated = False
self._measured_points: set = set()
self._fallback_attempts = 0
# ------------------------------------------------------------------ 场景
def _make_source_and_probe(self):
"""用真值布设一个"首次测向必为 direction"的课程场景(真值不进观测)。"""
rng = self._scene_rng
field = self.config.field
channel = rng.randint(field.channel_min, field.channel_max)
radius = field.omega_radius_m * math.sqrt(rng.random())
angle = rng.uniform(0.0, 2 * math.pi)
x, y = radius * math.cos(angle), radius * math.sin(angle)
r_eff = rng.uniform(field.r_eff_min_m, field.r_eff_max_m)
directional = self.problem == 4 and rng.random() < self.directional_probability
heading = rng.uniform(0.0, 360.0) if directional else 0.0
# 首次检测点:距离 [300, min(1450, r_eff-20)] 内随机,定向源取朝向
# ±80°(远离 90° 边界,保证 direction)。机器狗可到 Ω 外 [题面]。
max_distance = max(310.0, min(1450.0, r_eff - 20.0))
if max_distance < 310.0:
max_distance = 310.0
probe_distance = rng.uniform(300.0, max_distance)
probe_bearing = (heading + rng.uniform(-80.0, 80.0)
if directional else rng.uniform(0.0, 360.0))
probe_radians = math.radians(probe_bearing)
probe = (x + probe_distance * math.cos(probe_radians),
y + probe_distance * math.sin(probe_radians))
source = self._Source(channel=channel, x=x, y=y, r_eff_m=r_eff,
directional=directional, heading_deg=heading)
return source, probe
def _make_episode(self) -> bool:
"""重建模拟器并完成首次测向;返回 False 表示连续未得到 direction。"""
for _ in range(20):
self._source, probe = self._make_source_and_probe()
self._simulator = self._LocalSimulator(
[self._source], robot_id="rl-train",
spec=self._referee_spec(
self.config, max_real_duration_s=self.real_budget_s),
error_seed=(self.error_seed if self.error_seed is not None
else self._scene_rng.randint(0, 2 ** 31 - 1)),
stress_mode=True)
self._client = SimulatorClient(
"rl-train", config=self.config,
transport=self._LocalSimTransport(self._simulator),
log=SessionLog())
self._client.enter()
outcome = self._client.measure(probe, self._source.channel)
if outcome.result == DIRECTION:
self._channel = self._source.channel
self._belief = BeliefState(self.config)
self._belief.update_measure(self._channel, probe, outcome.result,
outcome.svd_deg)
self._blocked = []
self._directional_observed = False
self._clear_failed = False
self._measured_points = {self._point_key(probe)}
self._fallback_attempts = 0
return True
# near / no_signal 首测属于概率为零的边界或课程构造失败,换场景。
continue
return False
# ------------------------------------------------------------------ Gym API
@staticmethod
def _point_key(point) -> tuple[int, int]:
return (int(round(point[0] * 2)), int(round(point[1] * 2)))
def reset(self, *, seed: int | None = None, options: dict | None = None):
super().reset(seed=seed)
if seed is not None:
self._scene_rng = Random(seed)
self._steps = 0
self._terminated = False
self._truncated = False
if not self._make_episode():
raise RuntimeError("连续 20 次无法构造 direction 首测场景")
observation = self._observe()
return observation, self._info()
def _observe(self) -> np.ndarray:
region = self._belief.candidate_set(self._channel)
if region is None:
raise RuntimeError("首次测向后信念状态缺失候选集")
return build_observation(
region, self._client.state.position, self._channel, self.config,
self.problem, self._blocked, self._belief,
virtual_time_s=self._client.state.virtual_time_s)
def _info(self) -> dict:
source = self._source
return {
"channel": self._channel,
"source": {"x": source.x, "y": source.y, "r_eff_m": source.r_eff_m,
"directional": source.directional,
"heading_deg": source.heading_deg},
"steps": self._steps,
"blocked_count": len(self._blocked),
"directional_observed": self._directional_observed,
"fallback_attempts": self._fallback_attempts,
}
def step(self, action):
if self._terminated or self._truncated:
raise RuntimeError("step() 需要在 terminated/truncated 后先 reset()")
if not 0 <= int(action) < NUM_ACTIONS:
raise ValueError(f"动作 {action} 超出 Discrete({NUM_ACTIONS})")
action = int(action)
self._steps += 1
region = self._belief.candidate_set(self._channel)
if region is None:
return self._finish(observation=self._observe(), reward=-20.0,
terminated=True, truncated=False)
# 达到清除证书且上次清除失败已被新测量重置:先自动清除(闭环语义)。
cert = clear_certificate(region, self._channel, self.config)
if cert.certified and not self._clear_failed:
return self._try_clear(cert, observation=self._observe())
old_virtual = self._client.state.virtual_time_s
old_radius = region.mec()[1]
candidates = candidate_actions(region, self._client.state.position,
self.config, self._blocked)
point = candidates[action]
try:
outcome = self._client.measure(point, self._channel)
self._belief.update_measure(self._channel, point, outcome.result,
outcome.svd_deg)
except Exception:
return self._finish(observation=self._observe(), reward=-20.0,
terminated=True, truncated=False)
cost_s = self._client.state.virtual_time_s - old_virtual
reward = -cost_s / 100.0
if self._point_key(point) in self._measured_points:
reward -= 0.75 # 重复测量浪费一次检测+移动,抑制策略回路
else:
self._measured_points.add(self._point_key(point))
new_region = self._belief.candidate_set(self._channel)
new_radius = new_region.mec()[1] if new_region is not None else 0.0
if outcome.result == DIRECTION:
# 密集成型奖励:一次测量把包围半径缩得越多越好。
shrink = max(0.0, old_radius - new_radius)
reward += 3.0 * shrink / max(old_radius, 1.0)
elif outcome.result == NEAR:
reward += 1.5
elif outcome.result == NO_SIGNAL:
center = region.mec()[0]
safe = safe_disk_radius(region, self.config)
if safe >= 0 and math.dist(point, center) <= safe + 1e-6:
if not self._directional_observed:
self._directional_observed = True
reward += 2.0 # 首次获得"朝向遮挡"证据(问题4关键分支)
self._blocked.append(point)
self._clear_failed = False
cert = clear_certificate(new_region, self._channel, self.config) \
if new_region is not None else None
observation = self._observe()
if cert is not None and cert.certified:
return self._try_clear(cert, observation=observation,
reward=reward)
if self.problem == 4 and len(self._blocked) >= self.config.search.max_blocked_probes:
return self._fallback_clear(observation=observation, reward=reward)
if self._steps >= self.max_episode_steps:
return self._finish(observation=observation, reward=reward - 2.0,
terminated=False, truncated=True)
return observation, reward, False, False, self._info()
def _fallback_clear(self, *, observation, reward: float):
"""问题4后备清除语义:遮挡梯子用尽后按 20m 网格覆盖逐格 /clear。
与 DirectionalExtension._fallback_clear 同口径;训练环境借此让策略
学到"梯子耗尽的代价",运行闭环仍由原后备清除函数执行。
"""
region = self._belief.candidate_set(self._channel)
cells = fallback_clear_cells(region.polygon, self.config)
self._fallback_attempts = len(cells)
if len(cells) > self.config.search.fallback_max_cells:
return self._finish(observation=observation, reward=reward - 3.0,
terminated=False, truncated=True)
attempts = 0
while cells:
cell = min(cells, key=lambda c: (
math.dist(self._client.state.position, c), c))
cells.remove(cell)
attempts += 1
old_virtual = self._client.state.virtual_time_s
outcome = self._client.clear(cell, self._channel)
reward += -(self._client.state.virtual_time_s - old_virtual) / 100.0
if outcome.result == CLEAR_SUCCESS:
self._belief.mark_cleared(self._channel)
reward += 8.0
self._fallback_attempts = attempts
return self._finish(observation=observation, reward=reward,
terminated=True, truncated=False)
self._fallback_attempts = attempts
return self._finish(observation=observation, reward=reward - 20.0,
terminated=True, truncated=False)
def _try_clear(self, cert, *, observation, reward: float = 0.0):
old_virtual = self._client.state.virtual_time_s
outcome = self._client.clear(cert.center, self._channel)
cost_s = self._client.state.virtual_time_s - old_virtual
reward += -cost_s / 100.0
if outcome.result == CLEAR_SUCCESS:
self._belief.mark_cleared(self._channel)
reward += 10.0 + 0.25 * max(0, self.max_episode_steps - self._steps)
return self._finish(observation=observation, reward=reward,
terminated=True, truncated=False)
self._clear_failed = True
reward -= 1.0
if self._steps >= self.max_episode_steps:
return self._finish(observation=observation, reward=reward,
terminated=False, truncated=True)
return observation, reward, False, False, self._info()
def _finish(self, *, observation, reward, terminated, truncated):
self._terminated = bool(terminated)
self._truncated = bool(truncated)
info = self._info()
info["cleared"] = (self._belief.channels[self._channel].status.value
== "cleared")
return observation, float(reward), self._terminated, self._truncated, info
def close(self):
return None
+173
View File
@@ -0,0 +1,173 @@
"""RL 状态特征与候选动作生成(问题3/4 共用,运行时与训练环境同源)。
设计目标:把"下一检测点去哪"这一连续决策转化为固定顺序的离散动作,
让 PPO 策略在 GPU 上学习,同时不触碰闭环的保守性证明。候选集合始终由
既有几何规则生成(保证接收盘、对称交会、定向遮挡梯子),RL 只负责在
这些物理可行候选中排序;证书、覆盖证明与后备清除仍由 planner 层负责。
观测维度与候选编号一旦训练即成为模型接口,不得随意重排:
- 全局特征 41 维(含频道 one-hot 20 维);
- 每个候选 6 维(相对位移、移动耗时、到 MEC 圆心距离、方位、保证接收标记),
共 60 × 6 = 360 维;
- 总观测 401 维。
"""
from __future__ import annotations
import math
import numpy as np
from ..config import ProblemConfig, Vec2
from ..planner import candidate_points, safe_disk_radius
from ._constants import GLOBAL_DIM, LADDER_SLOTS, NUM_ACTIONS, OBS_DIM, PER_ACTION_DIM
__all__ = [
"GLOBAL_DIM",
"LADDER_SLOTS",
"NUM_ACTIONS",
"OBS_DIM",
"PER_ACTION_DIM",
"candidate_actions",
"build_observation",
"empty_observation",
]
# 候选编号布局(训练/推理共享):
# 0 保证接收盘圆心(或宽候选集 MEC 圆心)
# 1..36 保证接收盘 3 环 × 12 方向
# 37..51 相对"机器人->MEC 圆心"来向的对称交会候选 3 环 × 5 偏角
# 52..59 定向遮挡环探梯子 8 个方向(问题3也保留,作为宽环探索)
_RING_RATIOS = (0.4, 0.75, 1.0)
_RING_ANGLES = 12
_GEO_OFFSETS = (-math.pi / 3, -math.pi / 6, 0.0, math.pi / 6, math.pi / 3)
def _probe_radius(region_radius_m: float, safe_m: float) -> float:
"""环候选的基准半径:有保证接收盘用盘半径,否则退化为 MEC 半径。"""
if safe_m >= 0.0:
return safe_m
return max(region_radius_m, 10.0)
def candidate_actions(region, position: Vec2, config: ProblemConfig,
blocked: list[Vec2] | None = None) -> list[Vec2]:
"""生成固定顺序的 60 个候选检测点。
参数 region 为 belief.CandidateRegion(任意凸候选集);blocked 是
定向遮挡记录(问题4),问题3 传空列表即可。返回顺序与模型训练时一致。
"""
center, radius = region.mec()
safe = safe_disk_radius(region, config)
base_radius = _probe_radius(radius, safe)
points = candidate_points(tuple(center), base_radius,
_RING_RATIOS, _RING_ANGLES)
assert len(points) == 37
base_angle = math.atan2(position[1] - center[1],
position[0] - center[0])
for fraction in _RING_RATIOS:
for offset in _GEO_OFFSETS:
angle = base_angle + offset
points.append((center[0] + base_radius * fraction * math.cos(angle),
center[1] + base_radius * fraction * math.sin(angle)))
blocked = list(blocked or [])
first = blocked[0] if blocked else None
anchor = 0.0
if first is not None:
anchor = math.atan2(first[1] - center[1], first[0] - center[0])
ladder_radius = max(radius, 20.0)
for slot in range(LADDER_SLOTS):
angle = anchor + slot * math.pi / 4
points.append((center[0] + ladder_radius * math.cos(angle),
center[1] + ladder_radius * math.sin(angle)))
assert len(points) == NUM_ACTIONS
return points
def _last_measurement_features(belief) -> tuple[tuple[float, float, float],
tuple[float, float]]:
"""末次读数的 one-hot + 方向角 sin/cos(无读数取零)。"""
readings = getattr(belief, "measurements", None) or []
if not readings:
return (1.0, 0.0, 0.0), (0.0, 0.0)
last = readings[-1]
result = last.get("result")
no_signal = 1.0 if result == "no_signal" else 0.0
direction = 1.0 if result == "direction" else 0.0
near = 1.0 if result == "near" else 0.0
svd = last.get("svd_deg")
if direction and svd is not None:
radians = math.radians(float(svd))
return (no_signal, direction, near), (math.sin(radians), math.cos(radians))
return (no_signal, direction, near), (0.0, 0.0)
def build_observation(region, position: Vec2, channel: int, config: ProblemConfig,
problem: int, blocked: list[Vec2] | None = None,
belief=None, virtual_time_s: float = 0.0) -> np.ndarray:
"""构造 PPO 观测向量(shape=(401,),与训练环境逐位一致)。"""
field, search = config.field, config.search
center, radius = region.mec()
safe = safe_disk_radius(region, config)
readings, svd_pair = _last_measurement_features(belief)
xs = [p[0] for p in region.polygon]
ys = [p[1] for p in region.polygon]
global_features = [
position[0] / 2000.0,
position[1] / 2000.0,
(channel - field.channel_min) / max(field.channel_max - field.channel_min, 1),
center[0] / 2000.0,
center[1] / 2000.0,
radius / 2000.0,
max(-1.0, min(1.0, safe / 1000.0)),
min(len(getattr(belief, "measurements", []) if belief is not None else []),
12) / 12.0,
min(float(virtual_time_s or 0.0), field.max_virtual_duration_s)
/ field.max_virtual_duration_s,
# 证书信息由运行时的 clear_certificate 判定传入;训练环境用 region.mec
# 的保守近似:radius + slack <= 20 - margin 即可清除。
1.0 if radius + region.slack_m <= field.clear_radius_m - search.clear_margin_m else 0.0,
float(problem == 4),
min(len(blocked or []) / max(search.max_blocked_probes, 1), 1.0),
(blocked[0][0] / 2000.0) if blocked else 0.0,
(blocked[0][1] / 2000.0) if blocked else 0.0,
readings[0],
readings[1],
readings[2],
svd_pair[0],
svd_pair[1],
(max(xs) - min(xs)) / 4000.0,
(max(ys) - min(ys)) / 4000.0,
]
one_hot = np.zeros(field.channel_max - field.channel_min + 1, dtype=np.float32)
one_hot[int(channel) - field.channel_min] = 1.0
global_features.extend(one_hot.tolist())
assert len(global_features) == GLOBAL_DIM
action_features: list[float] = []
for point in candidate_actions(region, position, config, blocked):
dx, dy = point[0] - position[0], point[1] - position[1]
distance = math.hypot(dx, dy)
action_features.extend([
dx / 2000.0,
dy / 2000.0,
min(distance / (config.cost.speed_mps * 1500.0), 2.0), # 移动耗时 / 1500s
math.hypot(point[0] - center[0], point[1] - center[1]) / 2000.0,
(math.degrees(math.atan2(dy, dx)) % 360.0) / 360.0,
1.0 if safe >= 0 and math.hypot(point[0] - center[0],
point[1] - center[1]) <= safe + 1e-6 else 0.0,
])
assert len(action_features) == NUM_ACTIONS * PER_ACTION_DIM
observation = np.asarray(global_features + action_features, dtype=np.float32)
assert observation.shape == (OBS_DIM,)
return observation
def empty_observation() -> np.ndarray:
"""依赖缺失时的零观测(仅用于错误提示路径,不进入训练数据)。"""
return np.zeros(OBS_DIM, dtype=np.float32)
+71
View File
@@ -0,0 +1,71 @@
"""RL 选点策略加载与推理(运行时接入 planner 的唯一入口)。
运行闭环对依赖保持惰性:只有显式选择 ``--strategy rl`` / ``--q4-strategy rl``
时才导入 stable_baselines3/torch。模型文件由 scripts/train_rl.py 生成,
观测/动作布局与训练环境共享 features 模块。
"""
from __future__ import annotations
from pathlib import Path
from ..config import ProblemConfig, Vec2
from ._constants import NUM_ACTIONS, OBS_DIM
from .features import build_observation, candidate_actions
class PointSelectionPolicy:
"""加载 PPO 模型,把当前候选集映射到 60 个候选检测点之一。"""
def __init__(self, model_path: str | Path, *, device: str = "auto"):
path = Path(model_path)
if not path.is_file():
raise RuntimeError(
f"未找到 RL 模型 {path}。请先安装 rl 依赖组并训练:\n"
f" uv sync --group rl --group dev\n"
f" uv run python scripts/train_rl.py --problem 3 --timesteps 120000\n"
f" uv run python scripts/train_rl.py --problem 4 --timesteps 40000")
try:
from stable_baselines3 import PPO
except ImportError as exc:
raise RuntimeError(
"缺少 RL 运行依赖(stable-baselines3)。请执行 "
"`uv sync --group rl` 后重试。") from exc
if device == "auto":
try:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
except ImportError:
device = "cpu"
self.device = device
self.model = PPO.load(str(path), device=device)
try:
obs_dim = self.model.observation_space.shape[0]
action_dim = self.model.action_space.n
except Exception as exc:
raise RuntimeError(f"模型空间与 RL 接口不兼容: {path}") from exc
if obs_dim != OBS_DIM or action_dim != NUM_ACTIONS:
raise RuntimeError(
f"模型接口失配: 期望 obs={OBS_DIM}/act={NUM_ACTIONS},"
f"实际 obs={obs_dim}/act={action_dim}。请用当前版本重新训练。")
self.path = path
def select(self, region, position: Vec2, channel: int,
config: ProblemConfig, problem: int,
blocked: list[Vec2] | None = None, belief=None,
virtual_time_s: float = 0.0) -> tuple[Vec2, int]:
"""返回 (检测点, 候选编号);确定性 argmax(评估/比赛口径)。"""
observation = build_observation(
region, position, channel, config, problem, blocked, belief,
virtual_time_s=virtual_time_s)
action, _state = self.model.predict(observation, deterministic=True)
action = int(action)
candidates = candidate_actions(region, position, config, blocked)
return candidates[action], action
def default_model_path(problem: int) -> Path:
"""各问题的默认模型位置(训练脚本输出口径)。"""
if problem not in (3, 4):
raise ValueError("problem 必须是 3 或 4")
return Path("output/rl") / f"q{problem}_ppo.zip"