chassis-sim 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. chassis_sim/__init__.py +12 -0
  2. chassis_sim/bridges/__init__.py +34 -0
  3. chassis_sim/bridges/adapters.py +200 -0
  4. chassis_sim/bridges/base.py +217 -0
  5. chassis_sim/bridges/raycast.py +124 -0
  6. chassis_sim/bridges/simple_bridge.py +1041 -0
  7. chassis_sim/chassis/__init__.py +20 -0
  8. chassis_sim/chassis/ackermann.py +131 -0
  9. chassis_sim/chassis/base.py +153 -0
  10. chassis_sim/chassis/diff_drive.py +66 -0
  11. chassis_sim/chassis/mecanum.py +69 -0
  12. chassis_sim/chassis/registry.py +81 -0
  13. chassis_sim/config/__init__.py +15 -0
  14. chassis_sim/config/curriculum.yaml +248 -0
  15. chassis_sim/config/env.yaml +49 -0
  16. chassis_sim/config/reward.yaml +274 -0
  17. chassis_sim/controllers/__init__.py +13 -0
  18. chassis_sim/controllers/_geometry.py +181 -0
  19. chassis_sim/controllers/base.py +253 -0
  20. chassis_sim/controllers/dwa.py +625 -0
  21. chassis_sim/controllers/pure_pursuit.py +156 -0
  22. chassis_sim/envs/__init__.py +51 -0
  23. chassis_sim/envs/chassis_env.py +1993 -0
  24. chassis_sim/envs/config.py +779 -0
  25. chassis_sim/envs/dynamics.py +203 -0
  26. chassis_sim/envs/observation.py +584 -0
  27. chassis_sim/envs/path_extractor.py +261 -0
  28. chassis_sim/envs/path_utils.py +160 -0
  29. chassis_sim/envs/privileged.py +215 -0
  30. chassis_sim/envs/reward.py +738 -0
  31. chassis_sim/envs/safety_gate.py +142 -0
  32. chassis_sim/eval/__init__.py +773 -0
  33. chassis_sim/gym_registration.py +51 -0
  34. chassis_sim/obs/__init__.py +40 -0
  35. chassis_sim/obs/spec.py +385 -0
  36. chassis_sim/planning/__init__.py +102 -0
  37. chassis_sim/planning/hybrid_astar.py +855 -0
  38. chassis_sim/planning/map_generator.py +1788 -0
  39. chassis_sim/planning/rs_curve.py +715 -0
  40. chassis_sim/py.typed +0 -0
  41. chassis_sim/render/__init__.py +66 -0
  42. chassis_sim/render/animation.py +130 -0
  43. chassis_sim/render/figure.py +1082 -0
  44. chassis_sim/render/recorder.py +232 -0
  45. chassis_sim/render/scene.py +330 -0
  46. chassis_sim/scenarios/__init__.py +114 -0
  47. chassis_sim/scenarios/data/circle.yaml +40 -0
  48. chassis_sim/scenarios/data/corridor.yaml +28 -0
  49. chassis_sim/scenarios/data/dense_static.yaml +88 -0
  50. chassis_sim/scenarios/data/dynamic_crossing.yaml +27 -0
  51. chassis_sim/scenarios/data/foldback_reverse.yaml +18 -0
  52. chassis_sim/scenarios/data/maze_grid.yaml +22 -0
  53. chassis_sim/scenarios/data/obstacles_dense.yaml +17 -0
  54. chassis_sim/scenarios/data/obstacles_dense_reverse.yaml +17 -0
  55. chassis_sim/scenarios/data/obstacles_medium.yaml +17 -0
  56. chassis_sim/scenarios/data/obstacles_sparse.yaml +18 -0
  57. chassis_sim/scenarios/data/straight.yaml +15 -0
  58. chassis_sim/scenarios/loader.py +585 -0
  59. chassis_sim/scenarios/reset_options.py +349 -0
  60. chassis_sim/scenarios/scenario.py +231 -0
  61. chassis_sim/sensors/__init__.py +26 -0
  62. chassis_sim/sensors/specs.py +167 -0
  63. chassis_sim/sensors/suite.py +393 -0
  64. chassis_sim-0.2.0.dist-info/METADATA +1236 -0
  65. chassis_sim-0.2.0.dist-info/RECORD +68 -0
  66. chassis_sim-0.2.0.dist-info/WHEEL +5 -0
  67. chassis_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
  68. chassis_sim-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,12 @@
1
+ """chassis-sim:三种底盘的 Gymnasium 环境。
2
+
3
+ import 本包即注册三个 id(ChassisSim-Ackermann-v0 / ChassisSim-DiffDrive-v0 /
4
+ ChassisSim-Mecanum-v0)。注册逻辑在 gym_registration.py,只依赖 gymnasium:
5
+ 环境本体按 entry_point 字符串延迟 import,不拉起 numba / matplotlib / torch。
6
+ """
7
+
8
+ from . import gym_registration as _gym_registration # noqa: F401 - 注册副作用
9
+
10
+ __version__ = "0.2.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,34 @@
1
+ """纯 numpy 的传感器桥(`SensorProvider` 实现)。
2
+
3
+ 对外暴露:抽象接口 `SensorProvider`、实现 `SimpleBridge`、把归一化动作接到吃
4
+ 物理命令的外部模拟器上的适配器 `PhysicalCommandBridge`,以及扫描通路的两个
5
+ 入口 `get_scan` / `scan_to_pointcloud`(后者原本就在 base.py 的模块层)。
6
+ `raycast` 里的两个下划线函数是实现细节,不在此转发(避免把私有符号变成公共 API)。
7
+
8
+ `get_scan` 在 `SensorProvider` 上是**方法**(基类实现直接抛 NotImplementedError,
9
+ 由 `SimpleBridge` 覆盖),这里转成一个模块级入口,这样
10
+ `from chassis_sim.bridges import get_scan, scan_to_pointcloud` 取到的是一对函数,
11
+ 不必先拿到具体桥的类。转发而非别名:别名会绑到基类那个抛异常的实现上。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from .adapters import PhysicalCommandBridge
19
+ from .base import SensorProvider, scan_to_pointcloud
20
+ from .simple_bridge import SimpleBridge
21
+
22
+
23
+ def get_scan(bridge: SensorProvider, side: str = "front") -> dict[str, Any]:
24
+ """转调 `bridge.get_scan(side)`(模块级入口;语义与实现完全由桥决定)。"""
25
+ return bridge.get_scan(side)
26
+
27
+
28
+ __all__ = [
29
+ "PhysicalCommandBridge",
30
+ "SensorProvider",
31
+ "SimpleBridge",
32
+ "get_scan",
33
+ "scan_to_pointcloud",
34
+ ]
@@ -0,0 +1,200 @@
1
+ """把"吃物理命令"的模拟器接到归一化 action 的 env 契约上。
2
+
3
+ env 的分工是"env 下发归一化动作、桥把它变成物理量再推演":
4
+ `envs/chassis_env.py:1294` 只把 `act` 交给 `bridge.step(act)`,映射由桥负责。
5
+ 本仓自带的 `SimpleBridge` 一步做完;但外部模拟器(ir-sim、ROS gazebo、真车
6
+ 接口,以及主仓 `ros_bridge/numpy_bridge.py` 的 `NumpySimBridge`)只吃物理量。
7
+ 直接挂上去,归一化动作会被当成物理量吃掉。
8
+
9
+ `PhysicalCommandBridge` 补上缺的那一段:
10
+
11
+ 归一化 action --chassis.physical_action()--> 物理向量
12
+
13
+ 三条约定:
14
+
15
+ * **映射走 `Chassis.physical_action()`**(纯函数,不读也不写执行器状态)。
16
+ `AckermannChassis.step()` 带一拍转向执行器滞后(`_prev_delta`),而**内层
17
+ 物理模拟器自己还有一次**;适配器再用 `step()` 会让滞后施加两次,整车响应
18
+ 慢一拍。
19
+ * **向量顺序由车型决定**(`chassis/base.py` 的 `physical_action`):
20
+ ackermann `[delta(rad), v(m/s)]`、diff_drive `[v(m/s), omega(rad/s)]`、
21
+ mecanum `[v(m/s), vy(m/s), omega(rad/s)]`。阿克曼的 delta 由底盘从
22
+ `_map_action()` 直接给出,**不**再做 `arctan(omega*L/v)` 反解:反解在
23
+ |v| < 1e-6 时无定义(只能沿用上一拍,成了跨步状态),200 例随机动作里还有
24
+ 64 例差 1 ulp。
25
+ * **执行器滞后只由内层模拟器施加恰好一次**:适配器不推进底盘状态、不调
26
+ `chassis.step()`、也不调 `chassis.reset_state()`。内层不带滞后时,它与
27
+ `SimpleBridge` 的位姿等价性会从 1.5e-15 崩到 9.76e-01。
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any
33
+
34
+ import numpy as np
35
+
36
+ from chassis_sim.chassis import Chassis
37
+
38
+ from .base import SensorProvider
39
+
40
+
41
+ class PhysicalCommandBridge(SensorProvider):
42
+ """把"吃物理命令的模拟器"接到归一化 action 的 env 契约上。
43
+
44
+ 归一化 action --chassis.physical_action()--> 物理向量 --inner.step()--> 物理桥
45
+
46
+ 物理向量按车型(`Chassis.physical_action()`):
47
+ ackermann `[delta, v]`、diff_drive `[v, omega]`、mecanum `[v, vy, omega]`。
48
+
49
+ 除了 `step` / `reset` / `set_dynamic_obstacles` / `get_sensor_reading`,
50
+ 其余成员都是**逐字透传**(同一个返回对象,不拷贝、不重建):适配器只改动作
51
+ 通路,不改传感器通路。`get_sensor_reading` 多一层可选能力判定:内层没有
52
+ 该方法时返回 `None`(详见该方法),因为"没有传感器"是合法状态,而不是错误。
53
+
54
+ Attributes:
55
+ inner: 内层物理桥;每次 `step` 收到一个 float64 ndarray,顺序见上。
56
+ chassis: 归一化动作的映射器。env 注入桥时要求
57
+ `bridge.chassis is env.chassis`(`envs/chassis_env.py:438`),所以
58
+ 这里保存调用方给的**同一个实例**,不自己 new 一个。
59
+ """
60
+
61
+ def __init__(self, inner: SensorProvider, chassis: Chassis) -> None:
62
+ """绑定内层物理桥与动作映射器。
63
+
64
+ Args:
65
+ inner: 吃物理命令的桥(ir-sim / gazebo / NumpySimBridge 之类)。
66
+ 只要求它实现 `SensorProvider` 的契约,不要求是本包类型;它
67
+ 收到的向量顺序必须与 `chassis.physical_action()` 一致。
68
+ chassis: 与 env 共享的底盘实例,只用来做归一化 -> 物理的映射。
69
+ """
70
+ self.inner = inner
71
+ self.chassis = chassis
72
+
73
+ # ── 动作通路:归一化 action -> 物理向量 ──────────────────────────────
74
+ def to_physical(self, action: np.ndarray) -> np.ndarray:
75
+ """归一化动作 -> 内层桥要的物理向量(float64,长度 = action_dim)。
76
+
77
+ 顺序由车型决定:ackermann `[delta, v]`、diff_drive `[v, omega]`、
78
+ mecanum `[v, vy, omega]`。**纯函数**:同一 action 永远得到逐位相同的
79
+ 向量,与底盘执行器状态(阿克曼的 `_prev_delta`)无关。单独暴露是为了
80
+ 让使用方与测试能直接看一条动作对应的物理命令,而不必先推进仿真。
81
+ """
82
+ return np.asarray(self.chassis.physical_action(action), dtype=np.float64)
83
+
84
+ def step(self, action: np.ndarray) -> None:
85
+ """把归一化动作映射成物理向量,转交内层桥(积分在内层发生)。"""
86
+ self.inner.step(self.to_physical(action))
87
+
88
+ def reset(
89
+ self,
90
+ obstacles: list | None = None,
91
+ path_waypoints: np.ndarray | None = None,
92
+ initial_heading: float | None = None,
93
+ ) -> None:
94
+ """复位内层桥(适配器自身无状态,只转发)。
95
+
96
+ 刻意**不**调用 `chassis.reset_state()`:适配器从不调用 `chassis.step()`,
97
+ 底盘的执行器状态(`_prev_delta`)对纯函数映射没有影响,而不存在的那次
98
+ 状态推进也就不需要复位。位姿与执行器状态由 env / 内层桥各自负责。
99
+
100
+ Args:
101
+ obstacles: 原样转发给内层桥。
102
+ path_waypoints: 原样转发给内层桥。
103
+ initial_heading: 原样转发给内层桥。
104
+ """
105
+ self.inner.reset(
106
+ obstacles=obstacles,
107
+ path_waypoints=path_waypoints,
108
+ initial_heading=initial_heading,
109
+ )
110
+
111
+ # ── 传感器与判定:逐字透传 ──────────────────────────────────────────
112
+ def get_lidar_front(self) -> np.ndarray:
113
+ """透传内层桥的前向扫描。"""
114
+ return self.inner.get_lidar_front()
115
+
116
+ def get_lidar_rear(self) -> np.ndarray:
117
+ """透传内层桥的后向扫描。"""
118
+ return self.inner.get_lidar_rear()
119
+
120
+ def get_odometry(self) -> np.ndarray:
121
+ """透传内层桥的里程计 `[x, y, theta, v, omega]`。"""
122
+ return self.inner.get_odometry()
123
+
124
+ def get_path(self) -> np.ndarray:
125
+ """透传内层桥的车体系路径 `(N, 3)`。"""
126
+ return self.inner.get_path()
127
+
128
+ def get_path_global(self) -> np.ndarray:
129
+ """透传内层桥的世界系路径 `(N, 3)`。"""
130
+ return self.inner.get_path_global()
131
+
132
+ def check_collision(self) -> bool:
133
+ """透传内层桥的碰撞判定。"""
134
+ return self.inner.check_collision()
135
+
136
+ def check_goal_reached(self, threshold: float = 0.3) -> bool:
137
+ """透传内层桥的到点判定。
138
+
139
+ Args:
140
+ threshold: 到点距离阈值(米),原样转发。
141
+ """
142
+ return self.inner.check_goal_reached(threshold)
143
+
144
+ def close(self) -> None:
145
+ """透传内层桥的资源释放。"""
146
+ self.inner.close()
147
+
148
+ @property
149
+ def goal_heading(self) -> float:
150
+ """透传内层桥的目标期望朝向。"""
151
+ return self.inner.goal_heading
152
+
153
+ def get_scan(self, side: str = "front") -> dict[str, Any]:
154
+ """透传内层桥的扫描字典(`side` 原样转发)。
155
+
156
+ Args:
157
+ side: 传感器侧别,取值由内层桥决定。
158
+ """
159
+ return self.inner.get_scan(side)
160
+
161
+ def get_sensor_reading(self) -> dict[str, Any] | None:
162
+ """转发内层桥的可选传感器读数;内层不支持该能力时返回 `None`。
163
+
164
+ 传感器是**可选能力**(与 `set_dynamic_obstacles` 的"必须响亮报错"
165
+ 正相反):内层桥压根没有这个方法(主仓 NumpySimBridge / ir-sim
166
+ SimBridge 就是这样),或只继承了 `SensorProvider` 的非抽象默认实现,
167
+ 都视为"未启用可选传感器",返回 `None` 而不是抛 `AttributeError` ——
168
+ 否则 env 侧 `getattr` 兜底(`envs/chassis_env.py:1735`)会被本类的
169
+ 具体方法架空,第一步就崩。
170
+
171
+ Returns:
172
+ 内层读数(`None` 也照原样返回),或内层不支持时的 `None`。
173
+ """
174
+ impl = getattr(self.inner, "get_sensor_reading", None)
175
+ if impl is None or (
176
+ getattr(impl, "__func__", None) is SensorProvider.get_sensor_reading
177
+ ):
178
+ return None
179
+ return impl()
180
+
181
+ def set_dynamic_obstacles(self, specs: list | None = None) -> None:
182
+ """转发给内层桥;内层不支持时**响亮地**报错,不静默跳过。
183
+
184
+ Args:
185
+ specs: 动态障碍规格列表(`None` / `[]` = 清空),原样转发。
186
+
187
+ Raises:
188
+ NotImplementedError: 内层桥只实现了静态场景(没有覆盖
189
+ `SensorProvider.set_dynamic_obstacles`)。
190
+ """
191
+ impl = getattr(self.inner, "set_dynamic_obstacles", None)
192
+ if impl is None or (
193
+ getattr(impl, "__func__", None) is SensorProvider.set_dynamic_obstacles
194
+ ):
195
+ raise NotImplementedError(
196
+ f"内层桥 {type(self.inner).__name__} 不支持动态障碍"
197
+ "(dynamic_obstacles):适配器只做转发,不自己维护障碍物;"
198
+ "请改用支持动态障碍的内层桥"
199
+ )
200
+ impl(specs)
@@ -0,0 +1,217 @@
1
+ """SensorProvider 抽象接口 —— 与主仓 ros_bridge/sensor_provider.py 逐字同构。
2
+
3
+ 签名(9 个抽象方法 + 1 个抽象属性)按字节照抄
4
+ `/home/zhao/drl-sim/ros_bridge/sensor_provider.py`,只把英文 docstring 译成中文:
5
+ 消费方(envs / 控制器 / 回放脚本)按位置或关键字调用这些方法,任何签名漂移都会让
6
+ 旧桥与新桥不可互换。
7
+
8
+ 实现者:
9
+ SimpleBridge —— 本包纯 numpy 的射线投射实现(bridges/simple_bridge.py)
10
+ 旧仓 SimBridge / ROS2Bridge / MockBridge —— 同签名,可直接替换
11
+
12
+ 在上面的**抽象**接口之外,本模块还提供两项**附加(非抽象)**能力,供需要 ROS
13
+ 式扫描元数据的消费方使用;不实现它们也不影响已有桥的可用性:
14
+
15
+ SensorProvider.get_scan() —— 单侧扫描字典(字段见 SCAN_FIELDS,对齐 ROS LaserScan)
16
+ scan_to_pointcloud() —— 扫描字典 -> 世界系 (N, 2) float64 点云
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from collections.abc import Mapping
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ # ROS sensor_msgs/LaserScan 的十个字段,**顺序即字典键序**(消费方按 ROS 顺序读)。
28
+ SCAN_FIELDS: tuple[str, ...] = (
29
+ "angle_min",
30
+ "angle_max",
31
+ "angle_increment",
32
+ "time_increment",
33
+ "scan_time",
34
+ "range_min",
35
+ "range_max",
36
+ "ranges",
37
+ "intensities",
38
+ "frame_id",
39
+ )
40
+
41
+
42
+ class SensorProvider(ABC):
43
+ """传感器数据与动作下发的抽象接口。"""
44
+
45
+ @abstractmethod
46
+ def reset(
47
+ self,
48
+ obstacles: list | None = None,
49
+ path_waypoints: np.ndarray | None = None,
50
+ initial_heading: float | None = None,
51
+ ) -> None:
52
+ """复位仿真状态。
53
+
54
+ Args:
55
+ obstacles: 可选的障碍物 kwargs 列表(动态创建用)。
56
+ path_waypoints: 可选的 (N, 2) 全局路径航点。
57
+ initial_heading: 可选的起始车头朝向(弧度)。
58
+ """
59
+ ...
60
+
61
+ @abstractmethod
62
+ def step(self, action: np.ndarray) -> None:
63
+ """用给定动作推进一个时间步。
64
+
65
+ Args:
66
+ action: [delta, v] 转向角与速度
67
+ """
68
+ ...
69
+
70
+ @abstractmethod
71
+ def get_lidar_front(self) -> np.ndarray:
72
+ """返回前向 LiDAR 扫描:[180] 个距离(米)。"""
73
+ ...
74
+
75
+ @abstractmethod
76
+ def get_lidar_rear(self) -> np.ndarray:
77
+ """返回后向 LiDAR 扫描:[180] 个距离(米)。"""
78
+ ...
79
+
80
+ @abstractmethod
81
+ def get_odometry(self) -> np.ndarray:
82
+ """返回里程计:[x, y, theta, v, omega]。"""
83
+ ...
84
+
85
+ @abstractmethod
86
+ def get_path(self) -> np.ndarray:
87
+ """返回车体系下的全局路径航点:[N, 3] (dx, dy, dtheta)。"""
88
+ ...
89
+
90
+ @abstractmethod
91
+ def get_path_global(self) -> np.ndarray:
92
+ """返回世界系下**全部**路径航点,形状 (N, 3) [x, y, θ]。"""
93
+ ...
94
+
95
+ @abstractmethod
96
+ def check_collision(self) -> bool:
97
+ """机器人与任何障碍物重叠时返回 True。"""
98
+ ...
99
+
100
+ @abstractmethod
101
+ def check_goal_reached(self, threshold: float = 0.3) -> bool:
102
+ """机器人进入末航点 threshold 以内且朝向匹配时返回 True。"""
103
+ ...
104
+
105
+ @abstractmethod
106
+ def close(self) -> None:
107
+ """释放资源。"""
108
+ ...
109
+
110
+ @property
111
+ @abstractmethod
112
+ def goal_heading(self) -> float:
113
+ """目标点的期望朝向(用于终端奖励中的朝向对齐计算)。"""
114
+ ...
115
+
116
+ # ── 附加(非抽象)API:动态障碍 ────────────────────────────────────
117
+ def set_dynamic_obstacles(self, specs: list | None = None) -> None:
118
+ """换一组动态障碍(`None` / `[]` = 清空)。**非抽象**:不实现即视为不支持。
119
+
120
+ Raises:
121
+ NotImplementedError: 该桥不支持动态障碍(构造它的模拟器只实现了静态
122
+ 场景)。这里**故意响亮地报错**而不是静默跳过 —— 一个被静默忽略的
123
+ 动态障碍会让"碰撞没发生"看起来像策略变好了。
124
+ """
125
+ raise NotImplementedError(
126
+ f"{type(self).__name__} 不支持动态障碍(dynamic_obstacles):"
127
+ "该桥只实现了静态场景;请改用支持动态障碍的桥(如 SimpleBridge)"
128
+ )
129
+
130
+ # ── 附加(非抽象)API:ROS 式扫描元数据 ────────────────────────────
131
+ def get_scan(self, side: str = "front") -> dict[str, Any]:
132
+ """返回带元数据的单侧扫描字典(**非抽象**:不实现即视为不支持)。
133
+
134
+ Returns:
135
+ 含 SCAN_FIELDS 十个键的字典,键序与 ROS sensor_msgs/LaserScan 一致:
136
+ angle_min, angle_max, angle_increment, time_increment, scan_time,
137
+ range_min, range_max, ranges, intensities, frame_id。
138
+
139
+ 角度约定(实现方必须遵守,scan_to_pointcloud 依赖它):
140
+ 第 i 条射线的方位角 = angle_min + i * angle_increment,单位为弧度、
141
+ 以**车头**为 0;且
142
+ angle_increment == (angle_max - angle_min) / (len(ranges) - 1)。
143
+ 即 angle_max 是**最后一条射线**的方位角(不是"末尾再加一个步长"),
144
+ 与 np.linspace(angle_min, angle_max, len(ranges)) 端点包含的语义一致。
145
+
146
+ 数值约定:ranges 必须是 float32 一维数组;**无返回/超出量程的射线用
147
+ +inf** 表示,不得用 0 或 range_max 冒充有效测距(scan_to_pointcloud 依赖
148
+ np.isfinite 丢弃这些射线)。intensities 允许为 None(不建模回波强度)。
149
+
150
+ Args:
151
+ side: 传感器侧别,实现方各自的合法取值。
152
+
153
+ Raises:
154
+ NotImplementedError: 本实现不提供扫描元数据。
155
+ ValueError: side 不是合法取值(消息里必须列出合法值)。
156
+ """
157
+ raise NotImplementedError(
158
+ f"{type(self).__name__} 未实现 get_scan();只有提供扫描元数据的桥才支持"
159
+ )
160
+
161
+ # ── 附加(非抽象)API:可选传感器读数 ──────────────────────────────
162
+ def get_sensor_reading(self) -> dict[str, Any] | None:
163
+ """返回当前步的可选传感器读数(**非抽象**:不实现即视为不启用)。
164
+
165
+ 读数**默认只进 info**:obs 的维度由 ObsSpec 决定(0.2.0 起默认是
166
+ odom+path+goal+act 的 128 维),与是否启用传感器无关;只有 obs_spec 里
167
+ 用 lidar 段显式引用了某个传感器名,它的 scan 才会进观测。
168
+
169
+ Returns: dict[传感器名, 读数],每个读数本身是 dict,实现方只放已启用的项:
170
+
171
+ * Lidar: {"scan": (n_rays,) float32 或 None(延迟预热期 / 丢帧),
172
+ "scan_meta": {name, mount, n_rays, fov, angle_min, ...}}
173
+ * Imu: {ax, ay, yaw_rate, yaw, t}
174
+ * WheelOdom: {v_left, v_right, v, t}
175
+
176
+ Returns:
177
+ dict[str, Any],或 None(未启用任何可选传感器;此时 env 的 info 不新增
178
+ 任何键,信息路径与加入该接口前逐位相同)。
179
+ """
180
+ return None
181
+
182
+
183
+ def scan_to_pointcloud(
184
+ scan: Mapping[str, Any],
185
+ origin: tuple[float, float] = (0.0, 0.0),
186
+ heading: float = 0.0,
187
+ ) -> np.ndarray:
188
+ """把 get_scan() 的字典转换为世界系点云 (N, 2) float64。
189
+
190
+ 第 i 条射线的方位角按 angle_min + i * angle_increment 还原,它是**相对车头**
191
+ 的 bearing;世界系方向 = heading + bearing。
192
+
193
+ Args:
194
+ scan: 至少含 angle_min / angle_increment / ranges 的扫描字典。
195
+ origin: 传感器在**世界系**的 (x, y) 位置(米);注意不是车体后轴中心,
196
+ 前/后雷达各有安装偏移,需要由调用方叠加。
197
+ heading: 传感器在**世界系**的朝向(弧度),即车头朝向。
198
+
199
+ Returns:
200
+ (N, 2) float64 世界系坐标。ranges 里的非有限值(inf/nan,即"无返回")
201
+ 对应的射线整条丢弃,因此返回行数 <= len(ranges);全部无返回时为 (0, 2)。
202
+ """
203
+ ranges = np.asarray(scan["ranges"], dtype=np.float64).reshape(-1)
204
+ angle_min = float(scan["angle_min"])
205
+ angle_increment = float(scan["angle_increment"])
206
+ angles = angle_min + np.arange(ranges.size, dtype=np.float64) * angle_increment
207
+ angles = angles + float(heading)
208
+
209
+ finite = np.isfinite(ranges)
210
+ if not np.any(finite):
211
+ return np.zeros((0, 2), dtype=np.float64)
212
+
213
+ r = ranges[finite]
214
+ a = angles[finite]
215
+ ox = float(origin[0])
216
+ oy = float(origin[1])
217
+ return np.column_stack([ox + r * np.cos(a), oy + r * np.sin(a)])
@@ -0,0 +1,124 @@
1
+ """模块级 ray-casting 辅助函数 —— 从 numpy_bridge.py:30-132 原样移植。
2
+
3
+ 两个函数都是"射线 × 障碍物"维度上的全向量化实现(没有单条射线的 Python 循环),
4
+ 公式一字未改:圆的解析二次方程、矩形的"世界→局部"变换 + slab test。
5
+
6
+ ⚠ 保留的两条旧坑(注释即代码的一部分,别删):
7
+
8
+ * `_ray_rects_intersection` 里的世界→局部变换用 **R(−θ)**
9
+ (`l = (c·dx + s·dy, −s·dx + c·dy)`)。旧实现曾写成 cos(−θ)/sin(−θ) 搭配
10
+ 转置形式,两个取负相互抵消等于 R(+θ),把矩形镜像成 −θ 朝向 —— 非轴对齐时
11
+ 约 90% 射线量错。
12
+ * slab test 的退化分支必须保留 `parallel_x/parallel_y` 与 `out_x/out_y`:
13
+ 方向分量为 0 时 `t = ±inf` 会污染 `t_min`/`t_max` 的比较。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+
21
+ def _ray_circles_intersection(
22
+ origins: np.ndarray, # (R, 2)
23
+ directions: np.ndarray, # (R, 2) 单位向量
24
+ centers: np.ndarray, # (C, 2)
25
+ radii: np.ndarray, # (C,)
26
+ max_range: float,
27
+ ) -> np.ndarray: # (R,)
28
+ """每条射线到最近圆形交点的距离。
29
+
30
+ 对每一对 (射线, 圆) 使用 broadcasting 求解 |origin + t·direction − center|² = radius²,
31
+ 然后取每条射线的最小 *t*。
32
+ """
33
+ # to_center: (R, 1, 2) − (1, C, 2) → (R, C, 2)
34
+ to_center = origins[:, None, :] - centers[None, :, :]
35
+
36
+ # b = 2·(S−C)·D → (R, C)
37
+ b = 2.0 * np.sum(to_center * directions[:, None, :], axis=-1)
38
+
39
+ # c = |S−C|² − r² → (R, C)
40
+ c = np.sum(to_center * to_center, axis=-1) - radii[None, :] ** 2
41
+
42
+ disc = b * b - 4.0 * c # (R, C)
43
+ sqrt_disc = np.sqrt(np.maximum(disc, 0.0))
44
+
45
+ t1 = (-b - sqrt_disc) * 0.5
46
+ t2 = (-b + sqrt_disc) * 0.5
47
+
48
+ # 每对 (射线, 圆) 的最小正 t
49
+ t = np.where((t1 > 0.0) & (disc >= 0.0), t1, np.inf)
50
+ t = np.where((t2 > 0.0) & (disc >= 0.0) & (t2 < t), t2, t)
51
+
52
+ t_min = np.min(t, axis=1) # (R,)
53
+ t_min = np.where(np.isfinite(t_min), t_min, max_range)
54
+ return np.clip(t_min, 0.0, max_range)
55
+
56
+
57
+ def _ray_rects_intersection(
58
+ origins: np.ndarray, # (R, 2)
59
+ directions: np.ndarray, # (R, 2) 单位向量
60
+ centers: np.ndarray, # (M, 2)
61
+ half_dims: np.ndarray, # (M, 2) [半长, 半宽]
62
+ thetas: np.ndarray, # (M,) 矩形在世界坐标系中的朝向
63
+ max_range: float,
64
+ ) -> np.ndarray: # (R,)
65
+ """每条射线到最近矩形交点的距离。
66
+
67
+ 将每条射线转换到每个矩形的局部坐标系(AABB),然后应用标准的 slab test。
68
+ 在射线 × 矩形维度上完全向量化。
69
+ """
70
+ # ── 将射线起点转换到每个矩形的局部坐标系 ──────────────
71
+ # 世界→局部 需要 R(−θ):l = (c·dx + s·dy, −s·dx + c·dy),c=cosθ, s=sinθ。
72
+ # ⚠ 修复 (2026-08-17): 旧代码写成 cos(−θ)/sin(−θ) 搭配 R 的转置形式,
73
+ # 两个取负相互抵消等于 R(+θ) —— 即把矩形镜像成 −θ 朝向,
74
+ # 导致 LiDAR 看到的矩形与 env SAT 碰撞/真实几何不一致(非轴对齐时 ~90% 射线错误)。
75
+ cos_t = np.cos(thetas) # (M,)
76
+ sin_t = np.sin(thetas)
77
+
78
+ local_o = origins[:, None, :] - centers[None, :, :] # (R, M, 2)
79
+ local_ox = cos_t[None, :] * local_o[:, :, 0] + sin_t[None, :] * local_o[:, :, 1]
80
+ local_oy = -sin_t[None, :] * local_o[:, :, 0] + cos_t[None, :] * local_o[:, :, 1]
81
+
82
+ # ── 转换射线方向 ──────────────────────────────────────
83
+ local_dx = (
84
+ cos_t[None, :] * directions[:, None, 0]
85
+ + sin_t[None, :] * directions[:, None, 1]
86
+ )
87
+ local_dy = (
88
+ -sin_t[None, :] * directions[:, None, 0]
89
+ + cos_t[None, :] * directions[:, None, 1]
90
+ )
91
+
92
+ hl = half_dims[None, :, 0] # (1, M)
93
+ hw = half_dims[None, :, 1] # (1, M)
94
+
95
+ with np.errstate(divide="ignore", invalid="ignore"):
96
+ # ── slab test: x 轴 ─────────────────────────────────────
97
+ tx1 = (-hl - local_ox) / local_dx
98
+ tx2 = (hl - local_ox) / local_dx
99
+ tx_min = np.minimum(tx1, tx2)
100
+ tx_max = np.maximum(tx1, tx2)
101
+
102
+ # ── slab test: y 轴 ─────────────────────────────────────
103
+ ty1 = (-hw - local_oy) / local_dy
104
+ ty2 = (hw - local_oy) / local_dy
105
+ ty_min = np.minimum(ty1, ty2)
106
+ ty_max = np.maximum(ty1, ty2)
107
+
108
+ t_min = np.maximum(tx_min, ty_min)
109
+ t_max = np.minimum(tx_max, ty_max)
110
+
111
+ # ── 退化情况 ──────────────────────────────────────────
112
+ eps = 1e-10
113
+ parallel_x = np.abs(local_dx) < eps
114
+ parallel_y = np.abs(local_dy) < eps
115
+ out_x = parallel_x & ((local_ox < -hl) | (local_ox > hl))
116
+ out_y = parallel_y & ((local_oy < -hw) | (local_oy > hw))
117
+
118
+ t = np.where(t_min > 0.0, t_min, t_max)
119
+ valid = (t_min <= t_max) & (t_max > 0.0) & ~out_x & ~out_y
120
+ t = np.where(valid, t, np.inf)
121
+
122
+ t_min_ray = np.min(t, axis=1)
123
+ t_min_ray = np.where(np.isfinite(t_min_ray), t_min_ray, max_range)
124
+ return np.clip(t_min_ray, 0.0, max_range)