NuPlan接入实战:从AbstractPlanner继承到CI回归的完整工程链
1. 项目概述为什么要把自己的 Planner 塞进 NuPlan这真不是“为了用而用”NuPlan 不是玩具也不是教学演示工具——它是一套工业级自动驾驶规划算法的“压力测试场”。我第一次在某车企智驾团队看到他们用 NuPlan 跑一个新 planner 的回归测试时整个 pipeline 每天要调度 2000 个真实接管场景包括暴雨夜路、无标线施工区、鬼探头式行人横穿每个场景跑 5 种 ego vehicle 动态参数组合最后生成 37 项量化指标报告。那一刻我就明白所谓“把自己的 Planner 塞进去”本质是让算法从“能跑通”迈向“敢上车”的临门一脚。核心关键词NuPlan、Planner、仿真框架、AbstractPlanner、Hydra在这里不是孤立术语而是构成一套验证闭环的齿轮NuPlan是底盘——提供统一的场景加载器、传感器模拟器、动力学模型、评估器Planner是你要验证的“心脏”但必须按 NuPlan 的契约交付不能随心所欲输出轨迹仿真框架不是黑盒它强制你暴露 planner 的所有输入依赖如是否依赖高精地图、是否调用预测模块AbstractPlanner是接口契约——它定义了“一个合格 planner 必须长什么样”比如必须实现compute_trajectory()方法必须返回Trajectory对象必须在 100ms 内完成计算Hydra是配置中枢——它让你不用改一行代码就能切换训练数据集、仿真地图、ego vehicle 参数、甚至 planner 的超参组合这才是工业级可复现性的根基。这个项目标题里藏着三个被很多人忽略的真实需求第一不是“接入”而是“对齐”——你的 planner 可能用 PyTorch 写用 ROS 发布用自定义坐标系但 NuPlan 强制你把它“翻译”成 nuPlan 定义的EgoState、ObservedTrafficObjects、MapAPI接口。这个过程暴露出你算法中隐藏的耦合点比如硬编码了某张 HD Map 的 CRS 坐标系第二不是“跑起来就行”而是“跑得准、跑得稳、跑得可比”——NuPlan 的Scenario对象封装了精确到毫秒的时间戳、厘米级的定位真值、带置信度的障碍物轨迹你 planner 输出的每一条轨迹都会被严格比对ego_progress,offroad_rate,collision_rate等 23 项指标这些数字直接决定算法能否进入实车路测清单第三不是“单次验证”而是“持续回归”——当你用 Hydra 配置好nuplan-devkit的nuplan-planning子模块后每次 git push 都能自动触发 CI 流水线在 4 个不同城市区域的 1000 场景上跑全量回归失败项会精准定位到具体场景 ID 和 metric 偏差值。这才是现代自动驾驶研发的真实节奏。适合谁来读如果你是高校研究者正为 ICRA 论文补实验需要快速验证新 planner 在 nuPlan 标准 benchmark 上的表现如果你是初创公司算法工程师手头有自研的 mission planner但客户要求提供 nuPlan 兼容性报告如果你是 Tier1 工程师正在把量产版 planner 迁移到新平台需要一套不依赖实车的离线验证基线——那么这篇内容就是你跳过 3 周踩坑时间、直奔核心接口的实操手册。它不讲 NuPlan 架构图不堆概念只告诉你哪几行代码必须改、哪几个配置文件必须动、哪几个报错意味着你的 planner 还没真正“活”在 NuPlan 里。2. 整体设计与思路拆解为什么必须从 AbstractPlanner 继承绕不开的三大设计约束把 planner 塞进 NuPlan最常见错误是试图“绕过接口”——比如直接修改nuplan-planning的run_simulation.py把你的 planner 函数硬塞进主循环或者写个 wrapper 脚本把 NuPlan 输出的scenario转成 JSON再喂给自己的 planner 服务。这两种方式在本地 demo 阶段看似可行但一旦进入团队协作或 CI 流水线立刻崩盘。原因在于 NuPlan 的设计哲学有三个不可妥协的硬约束它们共同决定了你必须老老实实继承AbstractPlanner2.1 约束一状态一致性State Consistency——你的 planner 不能“失忆”NuPlan 的仿真引擎是状态驱动的。它每推进一个 timestep默认 0.1s就会调用一次 planner 的compute_trajectory()方法并传入当前完整的EgoState含位置、速度、加速度、朝向角、角速度和ObservedTrafficObjects含所有障碍物的 6DoF 状态及不确定性协方差。关键点在于NuPlan 不保证连续两次调用之间你的 planner 实例是同一个对象。在分布式仿真中一个 scenario 可能被切分成多个 chunk由不同 worker 执行每个 worker 都会新建一个 planner 实例。如果你的 planner 内部维护了状态比如基于卡尔曼滤波的 ego motion 估计、历史轨迹缓存、动态障碍物交互建模的 hidden state而没有通过AbstractPlanner规定的initialize()和cleanup()方法进行显式管理那么在跨 chunk 或重启仿真时状态就会丢失导致轨迹突变。我见过一个团队的 planner 在单场景仿真中完美但跑 100 场景 batch 时第 47 场景开始出现周期性抖动——最终定位到是内部一个未持久化的 velocity bias 估计器在 worker 重建时重置为零。提示AbstractPlanner.initialize()是唯一被保证在仿真开始前、且仅执行一次的方法。所有需要初始化的状态如滤波器、神经网络权重加载、地图缓存必须放在这里。AbstractPlanner.cleanup()则用于释放资源如关闭数据库连接、清空 GPU cache虽然不常被调用但必须存在。2.2 约束二输入标准化Input Standardization——NuPlan 不接受“方言”你的 planner 可能习惯接收ros::sensor_msgs::PointCloud2或numpy.ndarray形式的激光雷达点云但 NuPlan 的Scenario只提供LidarPc对象它封装了点云坐标point_cloud、时间戳timestamp_us、传感器位姿ego_pose以及一个MapAPI接口。如果你强行在compute_trajectory()里做类型转换会遇到两个问题性能瓶颈LidarPc.point_cloud是torch.Tensor而你的点云处理 pipeline 可能基于open3d或pcl频繁的 tensor ↔ numpy ↔ open3d.PointCloud 转换会吃掉 30% 的计算时间语义丢失LidarPc中的ego_pose是相对于全局坐标系的 SE3 变换而你的 pipeline 可能默认以车辆中心为原点。如果没做正确坐标系对齐轨迹起点就会偏移 2 米以上。NuPlan 强制你通过MapAPI获取地图信息而不是直接读取.osm或.lanelet2文件。MapAPI提供的是抽象后的Lane,LaneConnector,StopLine等对象每个对象都带有get_roadblock_id(),get_left_boundary(),get_speed_limit_mps()等方法。这意味着你的 planner 无法再依赖“地图文件路径”这种硬编码逻辑必须学会用 NuPlan 的“地图语言”思考——比如判断是否在路口不能查lane_id A123而要调用map_api.get_lane_segment(lane_id).is_intersection()。2.3 约束三配置可追溯性Config Traceability——Hydra 是你的“审计日志”这是最容易被忽视却最影响工程落地的一点。NuPlan 使用 Hydra 1.3 管理全部配置其核心思想是每一个运行实例的完整配置必须能从命令行参数或配置文件中 100% 还原。如果你的 planner 有一个config.yaml里面写了model_path: ./weights/best.pth那么当这个实验结果被质疑时你必须能证明这个best.pth确实是 commita1b2c3d下训练出来的训练时用的learning_rate是1e-4而不是5e-4数据增强开关use_augmentation是true。Hydra 通过hydra.main(config_pathconf, config_namedefault)装饰器将配置树注入到你的 planner 类中。当你继承AbstractPlanner后NuPlan 会自动将hydra.utils.instantiate(cfg.planner)的结果作为 planner 实例。这意味着你的 planner 类构造函数必须接收一个cfg参数并从中提取所有超参。我见过太多团队把超参写死在__init__里结果在 CI 中发现开发机跑的结果和 CI 服务器跑的结果不一致排查三天才发现是开发机用了旧版config.yaml而 CI 用的是 git master 分支的最新版。注意Hydra 的配置覆盖规则是命令行 config.yaml defaults/base.yaml。例如python nuplan/planning/script/run_simulation.py ... plannermy_planner model_path/data/new_weights.pth会覆盖config.yaml中的model_path。这个机制让你无需修改代码就能做 A/B 测试。这三大约束共同指向一个结论绕过AbstractPlanner的任何尝试都是在构建一个脆弱、不可复现、无法集成的“纸糊系统”。真正的塞入是让 planner 的基因里就刻着 NuPlan 的契约——状态可管理、输入可标准化、配置可追溯。接下来的所有实操都是围绕这三个原则展开的具体实现。3. 核心细节解析与实操要点从零创建一个可运行的 MyPlanner现在我们动手创建一个最小但完全合规的MyPlanner。这不是一个玩具 demo而是你未来所有 planner 的模板。我会逐行解释每一处设计决策背后的“为什么”并指出新手最容易栽跟头的细节。3.1 目录结构与文件组织为什么必须这样放NuPlan 的模块化设计要求你的 planner 必须放在特定路径下否则 Hydra 无法发现。标准结构如下假设你的工作区是nuplan-devkitnuplan-devkit/ ├── nuplan-planning/ # NuPlan 主代码 │ ├── conf/ # Hydra 配置根目录 │ │ ├── default.yaml # 主配置入口 │ │ └── planner/ # planner 专属配置目录 │ │ ├── my_planner.yaml # 你的 planner 配置 │ │ └── ... │ └── nuplan/planning/ # Python 包根目录 │ └── planner/ # planner 模块 │ ├── __init__.py │ ├── abstract_planner.py # NuPlan 提供的基类 │ └── my_planner.py # 你的实现重点 └── my_custom_planner/ # 你的独立代码库可选 └── models/ # 模型权重、配置等关键点my_planner.py必须放在nuplan/planning/planner/下且必须在__init__.py中声明from .my_planner import MyPlannermy_planner.yaml必须放在conf/planner/下且文件名必须与类名小写一致MyPlanner→my_planner.yaml如果你的 planner 依赖外部库如my_custom_models必须确保该库已安装pip install -e ./my_custom_planner且my_planner.py中的import语句能正确解析。提示不要把 planner 代码放在nuplan-devkit外部然后用sys.path.append()。Hydra 的插件发现机制依赖于 Python 包路径外部路径会导致配置无法加载。3.2 MyPlanner 类的核心骨架四行代码定生死以下是my_planner.py的最小可行骨架我逐行拆解# nuplan/planning/planner/my_planner.py from typing import Any, List, Optional import torch from nuplan.planning.scenario_builder.nuplan.nuplan_dataset import NuPlanDataset from nuplan.planning.simulation.planner.abstract_planner import AbstractPlanner from nuplan.planning.simulation.trajectory.trajectory import Trajectory from nuplan.planning.simulation.observation.observation_type import DetectionsTracks from nuplan.planning.simulation.planner.ml_planner.transform_utils import transform_predictions_to_states from nuplan.planning.simulation.trajectory.interpolated_trajectory import InterpolatedTrajectory class MyPlanner(AbstractPlanner): 一个符合 NuPlan 规范的自定义 planner 示例。 def __init__(self, cfg: Any) - None: 初始化 planner。 :param cfg: Hydra 配置对象包含所有超参。 super().__init__() # 必须调用父类初始化 self.cfg cfg # 保存配置后续所有参数从此读取 self.device torch.device(cfg.device if hasattr(cfg, device) else cpu) # 加载模型权重示例 self.model self._load_model(cfg.model_path) self.model.to(self.device) self.model.eval() # 关键必须设为 eval 模式否则 BatchNorm/ Dropout 行为异常 def name(self) - str: 返回 planner 名称用于日志和报告。 return MyPlanner def initialize(self) - None: 仿真开始前调用一次。用于状态初始化。 # 这里可以初始化 KF 滤波器、清空历史 buffer 等 self._reset_state() def _reset_state(self) - None: 重置内部状态。必须是幂等操作。 self._history_buffer [] # 示例存储最近 N 帧的 ego state self._kf_state None # 示例卡尔曼滤波器状态 def compute_trajectory(self, current_input: Any) - Trajectory: NuPlan 核心调用方法。必须返回 Trajectory 对象。 :param current_input: NuPlan 提供的当前 timestep 输入类型为 SimulationHistoryBuffer。 :return: 描述未来 8 秒轨迹的 Trajectory 对象。 # 1. 从 current_input 提取关键信息 ego_state current_input.ego_state # EgoState 对象 detections current_input.observation # DetectionsTracks 对象 map_api current_input.map_api # MapAPI 对象 # 2. 构建模型输入关键坐标系对齐 # NuPlan 的 ego_state.position 是 (x, y) 在全局坐标系但你的模型可能期望车辆坐标系 # 正确做法用 ego_state 转换所有坐标 local_detections self._transform_to_ego_frame(detections, ego_state) # 3. 模型推理 with torch.no_grad(): # 必须避免梯度计算浪费内存 predictions self.model( ego_stateego_state, detectionslocal_detections, map_apimap_api, deviceself.device ) # 4. 将模型输出转换为 NuPlan 要求的 Trajectory 格式 # predictions 可能是 [x, y, v, a, heading] 序列需插值为固定频率如 10Hz trajectory_states transform_predictions_to_states( predictions, ego_state, self.cfg.trajectory_horizon_s, # 8.0 self.cfg.trajectory_interval_s # 0.1 ) return InterpolatedTrajectory(trajectory_states) def _transform_to_ego_frame(self, detections: DetectionsTracks, ego_state: Any) - Any: 将检测结果从全局坐标系转换到车辆坐标系。 # 实现细节略核心是使用 ego_state.rear_axle 作为原点ego_state.orientation 作为旋转 # 必须处理 detections.objects 的每个 object 的 position、velocity、acceleration pass def _load_model(self, model_path: str) - torch.nn.Module: 加载模型权重。 # 示例支持 .pth 和 .onnx if model_path.endswith(.onnx): import onnxruntime as ort return ort.InferenceSession(model_path) else: model MyModel() # 你的模型类 model.load_state_dict(torch.load(model_path, map_locationcpu)) return model def cleanup(self) - None: 清理资源。 # 例如删除临时文件、关闭 ONNX session pass致命细节解析super().__init__()这一行绝不能省略。AbstractPlanner.__init__()内部设置了_initialized标志位如果没调用initialize()方法永远不会被执行self.model.eval()是血泪教训。我在一个项目中发现 planner 在仿真中轨迹突然发散最终定位到是模型中的 Dropout 层在train()模式下随机置零导致每帧输出不稳定transform_predictions_to_states()是 NuPlan 提供的工具函数它负责将模型输出的离散点序列如 50 个点插值为InterpolatedTrajectory所需的State对象列表每个State包含position,velocity,acceleration,heading等。不要自己手写插值NuPlan 的评估器NUPlanMetric严格依赖这个函数生成的State时间戳精度current_input的类型是SimulationHistoryBuffer它不是一个简单的字典而是一个包含ego_state,observation,map_api,mission_goal等属性的对象。直接print(current_input)只能看到nuplan.planning.simulation.history.simulation_history_buffer.SimulationHistoryBuffer object必须用dir(current_input)查看可用属性。3.3 Hydra 配置文件my_planner.yaml 的 7 个必填字段conf/planner/my_planner.yaml是你的 planner 的“身份证”它告诉 NuPlan 如何实例化你。一个健壮的配置至少包含以下 7 个字段# conf/planner/my_planner.yaml _target_: nuplan.planning.planner.my_planner.MyPlanner # 必填完整类路径 name: MyPlanner # 必填必须与类名一致 device: cuda:0 # 可选但强烈建议指定 model_path: /path/to/your/model.pth # 必填模型权重路径 trajectory_horizon_s: 8.0 # 必填规划时域秒 trajectory_interval_s: 0.1 # 必填轨迹点间隔秒 # 以下为自定义超参你的 __init__ 会用到 use_map_features: true max_num_objects: 32为什么_target_是灵魂Hydra 的instantiate()函数通过_target_字符串动态导入类并创建实例。如果写成_target_: MyPlanner缺少包路径Hydra 会报ImportError: No module named MyPlanner。正确的写法必须是nuplan.planning.planner.my_planner.MyPlanner即包路径.模块名.类名。name字段的双重作用在日志中标识 planner如[MyPlanner] Computing trajectory...在评估报告中作为指标前缀如MyPlanner/collision_rate。如果name和_target_中的类名不一致会导致报告混乱。trajectory_*参数的物理意义trajectory_horizon_s: 8.0意味着你的 planner 必须输出未来 8 秒的轨迹trajectory_interval_s: 0.1意味着轨迹点时间间隔为 0.1 秒即总共 80 个点。NuPlan 的InterpolatedTrajectory会严格按此频率采样。如果你的模型输出 50 个点transform_predictions_to_states()会自动线性插值补足如果你输出 100 个点它会降采样。但强烈建议模型输出点数与 80 严格匹配避免插值引入误差。注意所有字段名必须与你在MyPlanner.__init__中self.cfg.xxx读取的名称完全一致。Hydra 会将 YAML 中的键值对直接映射为cfg对象的属性。4. 实操过程与核心环节实现从本地调试到 CI 流水线部署现在我们把MyPlanner从代码变成可运行的 pipeline。这个过程分为四个递进阶段本地单场景调试、批量场景验证、CI 流水线集成、生产环境部署。每个阶段都有其独特的陷阱和技巧。4.1 阶段一本地单场景调试——用debug模式揪出所有“假阳性”不要一上来就跑 full dataset。先用 NuPlan 提供的 debug 场景确保你的 planner 能在 10 秒内完成一次完整推理。命令如下# 进入 nuplan-planning 目录 cd nuplan-devkit/nuplan-planning # 运行单场景 debug使用预下载的 mini dataset python nuplan/planning/script/run_simulation.py \ --sim_cfg_path conf/simulation/default.yaml \ --planner_cfg_path conf/planner/my_planner.yaml \ --scenarios_per_batch 1 \ --num_workers 1 \ --debug True \ --output_dir ./outputs/debug_my_planner \ --job_name debug_my_planner--debug True是救命开关它会启用以下功能在compute_trajectory()开头插入breakpoint()让你可以用 VS Code 或 pdb 逐行调试将current_input的所有属性ego_state,detections,map_api以.pkl格式保存到./outputs/debug_my_planner/debug_inputs/方便离线分析禁用多进程所有逻辑在主线程执行避免multiprocessing导致的pickle错误。调试时必查的 5 个检查点输入维度检查打开debug_inputs/00000.pkl用pickle.load()读取检查detections.objects的数量是否合理通常 5~20 个ego_state.position是否在预期范围内如(x123.45, y67.89)坐标系对齐检查在_transform_to_ego_frame()中打印ego_state.rear_axle.x, ego_state.rear_axle.y和第一个障碍物的object.center.x, object.center.y确认转换后障碍物坐标是否在(-50, 50)范围内车辆坐标系模型输入检查在self.model(...)调用前打印predictions.shape确认是否为(80, 5)80 个点每个点 x,y,v,a,headingTrajectory 构造检查在return InterpolatedTrajectory(...)前打印len(trajectory_states)必须等于 80时间消耗检查在compute_trajectory()开头和结尾加time.time()计算耗时。如果超过 100ms说明模型太重或预处理有瓶颈。实操心得我曾在一个项目中发现map_api的get_all_lanes_in_radius()调用耗时 80ms。解决方案是将其移到initialize()中预计算并缓存compute_trajectory()中只做 O(1) 查表。40.2 阶段二批量场景验证——用nuplan-metrics量化你的 planner单场景跑通只是开始。真正的考验是 1000 场景的批量验证。命令如下# 下载 nuPlan 官方 validation dataset约 20GB nuplan download --dataset-version nuplan-v1.1 --data-root ./nuplan-data # 运行批量仿真使用 4 个 worker 并行 python nuplan/planning/script/run_simulation.py \ --sim_cfg_path conf/simulation/default.yaml \ --planner_cfg_path conf/planner/my_planner.yaml \ --scenarios_per_batch 10 \ --num_workers 4 \ --output_dir ./outputs/batch_my_planner \ --job_name batch_my_planner \ --scenario_filter_type scenario_types \ --scenario_types [ego_mission, ego_lane_following] \ --data_root ./nuplan-data \ --version nuplan-v1.1关键参数解析--scenarios_per_batch 10每个 worker 一次处理 10 个场景减少进程启动开销--scenario_filter_type scenario_types只运行指定类型的场景避免在ego_parking这类复杂场景上卡住--data_root和--version必须与你下载的数据集路径和版本严格匹配否则报ScenarioNotFound。生成评估报告批量仿真完成后运行评估脚本python nuplan/planning/script/run_nuplan_evaluation.py \ --simulation_folder ./outputs/batch_my_planner \ --output_dir ./outputs/evaluation_my_planner \ --metrics_csv ./outputs/evaluation_my_planner/metrics.csv这会生成metrics.csv其中包含 23 项核心指标。重点关注Metric合格线说明ego_progress 95%规划轨迹覆盖的场景里程占比低于 95% 说明 planner 经常“停摆”offroad_rate 0.5%车辆驶出可行驶区域的比例高于 0.5% 说明地图理解或约束建模有问题collision_rate0.0%绝对红线任何非零值都意味着 planner 有致命缺陷comfort_acceleration 3.0 m/s²加速度绝对值均值过高说明运动平滑性差解读报告的技巧不要只看平均值。用pandas读取metrics.csv按scenario_id分组找出collision_rate 0的具体场景 ID然后用nuplan/planning/script/visualize_simulation.py可视化该场景python nuplan/planning/script/visualize_simulation.py \ --simulation_path ./outputs/batch_my_planner/scenario_12345 \ --output_dir ./outputs/vis_scenario_12345这会生成 HTML 动画你可以逐帧查看碰撞发生前 3 秒 planner 的轨迹预测、障碍物检测框、地图车道线精准定位 bug。4.3 阶段三CI 流水线集成——用 GitHub Actions 实现一键回归将验证流程自动化是工程落地的关键。以下是一个精简的.github/workflows/nuplan-ci.yml示例name: NuPlan Regression Test on: push: branches: [main] paths: - nuplan/planning/planner/my_planner.py - conf/planner/my_planner.yaml jobs: nuplan-test: runs-on: ubuntu-22.04 steps: - uses: actions/checkoutv3 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.9 - name: Install dependencies run: | pip install -e ./nuplan-devkit/nuplan-planning pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 - name: Download mini dataset (for fast test) run: nuplan download --dataset-version nuplan-v1.0-mini --data-root ./nuplan-data - name: Run simulation (10 scenarios) run: | python nuplan/planning/script/run_simulation.py \ --sim_cfg_path conf/simulation/default.yaml \ --planner_cfg_path conf/planner/my_planner.yaml \ --scenarios_per_batch 5 \ --num_workers 2 \ --output_dir ./outputs/ci_test \ --job_name ci_test \ --data_root ./nuplan-data \ --version nuplan-v1.0-mini - name: Run evaluation run: | python nuplan/planning/script/run_nuplan_evaluation.py \ --simulation_folder ./outputs/ci_test \ --output_dir ./outputs/ci_evaluation - name: Upload artifacts uses: actions/upload-artifactv3 with: name: nuplan-ci-report path: ./outputs/ci_evaluation/CI 设计的 3 个经验数据集选择CI 中永远用nuplan-v1.0-mini约 200MB而不是 full dataset。它包含 100 个精选场景能在 5 分钟内完成测试保证反馈速度失败快速定位在Run simulation步骤添加timeout-minutes: 10防止某个场景卡死导致整个 CI 挂起报告可视化将ci_evaluation/metrics.csv用pandas生成 Markdown 表格作为 PR comment 自动发布让每个 reviewer 一眼看到collision_rate是否为 0。4.4 阶段四生产环境部署——从仿真到实车的“信任传递”最后一步也是最难的一步如何让车队工程师相信你在 NuPlan 里跑出的 0 collision意味着实车也能安全答案是建立“信任链”。信任链的三环数据环确保 NuPlan 的scenario来源于真实接管事件real-world disengagement logs。我们团队的做法是将每一段实车录制的 CAN/LIDAR/VIDEO 数据用nuplan-scenario-builder工具转成标准nuplan-v1.1格式打上source: real_vehicle标签仿真环在 NuPlan 中用RealVehicleScenario类加载这些真实场景并开启sensor_noise: true模拟真实传感器噪声和ego_dynamics_noise: true模拟真实车辆执行偏差验证环对每个真实场景不仅跑collision_rate还跑trajectory_deviation_rms规划轨迹与人类驾驶员实际轨迹的 RMS 偏差要求deviation 0.5m。只有同时满足collision_rate 0且deviation 0.5m的 planner才被允许进入实车路测。最后分享一个小技巧在MyPlanner.compute_trajectory()中加入一行日志logger.info(fScenario {current_input.scenario.token[:8]} | Ego speed: {ego_state.velocity.magnitude():.2f} m/s)。当实车路测出现问题时你可以用这个 token 在 NuPlan 日志中快速定位到对应的仿真记录实现“实车-仿真”双向追溯。5. 常见问题与排查技巧实录那些文档里不会写的坑在把 12 个不同团队的 planner 塞进 NuPlan 的过程中我整理了一份高频问题速查表。这些问题往往不会在官方文档里出现但每个都足以让你卡住 1~3 天。5.1 ImportError: No module named nuplan —— 路径与环境的战争现象在python nuplan/planning/script/run_simulation.py中from nuplan.planning.simulation.planner.abstract_planner import AbstractPlanner报错。根本原因nuplan-devkit是一个 PEP 517 兼容的包但它的setup.py没有声明nuplan-planning为子包。直接pip install -e .只会安装nuplan根包nuplan.planning模块不可见。解决方案三选一推荐在nuplan-devkit/目录下运行pip install -e ./nuplan-planning注意是./nuplan-planning不是.临时方案在run_simulation.py开头添加import sys sys.path.insert(0, /path/to/nuplan-devkit/nuplan-planning)终极方案修改nuplan-devkit/pyproject.toml在[project]下添加packages [ {include nuplan}, {include nuplan-planning}, ]然后pip install -e .5.2 RuntimeError: Expected all tensors to be on the same device —— 设备不一致的幽灵现象compute_trajectory()中self.model(input_tensor)报错提示 tensor on cpu, but expected it to be on cuda:0