前几天有个做交通咨询的朋友来问我:SUMO 仿真环境搭到第三步,为什么总觉得"跑是跑起来了,但结果不对劲"——窗口里车在动,输出文件也生成了,可一旦有人追问"这个交叉口的平均延误到底是怎么算出来的""为什么同样的配置跑两遍数字不一样",就很难答上来。这个问题非常典型。前两步我们把工具装好、把路网导进来、让车跑起来,解决的是"能不能跑";第三步要解决的是"跑得对不对、数据能不能用、结论经不经得起追问"。这一步做完,仿真才从演示变成了分析工具。
这篇内容围绕SUMO 仿真环境的第三阶段搭建展开,重点不是安装,而是路网逻辑收尾、交通需求生成、信号与时序配置、输出口径统一、TraCI 外部接入以及批量实验与排错。适合已经把基础环境跑通、准备拿仿真结果写报告或做方案比选的人;如果你连 net.xml 都还没生成,建议先把前面的环节补上。下面所有步骤和参数都是我按实际项目里的做法整理的,命令行和配置文件都尽量给到可以直接抄的程度,同时会把"为什么这么设"讲清楚。
1. 第三阶段真正要啃的硬骨头:三类核心问题
进入第三步之后,最容易犯的错误是继续沿用第二步的心态——只要能跑通就算完成。实际上,从"能跑"到"跑得对"之间隔着三堵墙:路网的通行逻辑是否正确、交通需求是否和路网容量匹配、输出数据口径是否统一。这三件事任何一件没处理好,后面做出来的延误、排队、通行能力全是假的,而且假得很隐蔽,因为软件不会报错。
1.1 几何正确不等于通行逻辑正确
把一张 OSM 地图或者 CAD 图纸转成net.xml,netconvert 只保证了几何形状能连起来,不保证连接关系符合真实通行规则。我见过太多案例:交叉口内部该有的左转连接缺失,车辆在路口前莫名减速;两条同向车道在交叉口被生成了一个"直行车道 → 左转车道"的错误连接;主路和支路之间没有设置让行,支路车流直接切断主干道。这些问题在可视化里表现得很含蓄——车流速度偏低、局部排队长度异常——单看画面很难发现,但放进拥堵分析里就是灾难。
判断依据很简单:打开plain.con.xml看一眼连接表。正常情况下,一个四路交叉口每条进口道的每个车道,应该都能找到对应的下游连接,并且带方向标记(s 直行、l 左转、r 右转、t 掉头)。如果某个进口道只有直行连接,或者左转连接只从最右侧车道发出,那基本就是自动生成时判断错了。
1.2 需求流量与路网容量不匹配,跑出来的延误没有意义
仿真里有一个很反直觉的现象:需求超出容量太多时,平均延误并不会无限增长,而是会稳定在一个"看起来还行"的数值上。原因是被堵住的车根本没成功插入路网,它们在等待队列里被丢弃或者延迟出发,压根没进入统计。于是你看到的是"延误不高、路网畅通",实际上是路网入口处堆了一大堆没进去的车。
所以第三步必须做一件事:把车辆的插入失败数量当成一个核心指标来监控。summary输出里的waiting字段、statistic-output里的 teleport 统计,都是判断这个问题的主要依据。我一般的经验阈值是——如果稳定运行阶段 waiting 车辆持续超过总需求车辆的 5%,那这份需求就不能用来做延误对比,需要先调整出发分布或者降低流量。
1.3 输出口径不统一:同一份仿真的两套数据对不上
这个坑更隐蔽。比如你用summary里的平均车速去算行程时间,又用tripinfo里的 duration 去算,两边数值对不上,开会时就解释不清。原因通常是口径不同:summary统计的是当步所有在网车辆的快照平均值(包含正在等待的车),tripinfo统计的是完成行程的车辆的平均值(不包含还没到达的车)。统计期间也不一样,一个是逐步统计,一个是到达时一次性写入。
解决方式是在动手之前先定一套指标口径,把每个指标定义到"从哪来、怎么算、包含谁"。下面的表是我常用的口径约定,直接照着用能省掉很多扯皮。
| 指标 | 推荐数据源 | 计算口径 | 常见误用 |
|---|---|---|---|
| 平均行程时间 | tripinfo | 只统计已到达车辆,duration字段 | 拿 summary 的 meanTravelTime 直接对比 |
| 平均延误 | tripinfo | timeLoss字段,或用 duration 减自由流时间 | 把等待时间等同于延误 |
| 排队长度 | edgeData / laneData | 按周期取waitingTime或 halting 统计 | 用瞬时快照代替周期统计 |
| 通行量 | edgeData | 每个统计区间跨断面的车辆数 | 用插入数代替通过数 |
| 路网平均速度 | summary | 逐步平均值再做时间平均 | 直接取最后一步的值 |
2. 路网收尾:连接关系、转向禁行与优先权的三处硬伤
前面已经说过路网逻辑的重要性,这一节把具体怎么改讲透。netconvert 提供了大量参数来影响连接生成,但绝大多数人只会用默认值,结果是问题被一路带到仿真里。我的原则是:能用参数解决的不手改,必须手改的单独维护一个补丁文件,保证下次重新转路网时改动不会丢。
2.1 交叉口内部连接是怎么算出来的
netconvert 生成连接时,大致按几个步骤走:识别哪些车道引向同一个下游边,按车道位置和几何角度给每个潜在连接打分,然后用一套启发式规则挑选可行的车道配对,最后为每条连接计算方向和冲突关系。这套机制在标准十字路口表现不错,但在畸形交叉口、匝道汇入、单行道与双向路混排的地方经常出错。
几个真正有用的参数:
netconvert --sumo-net-file base.net.xml \ --plain-output-prefix plain \ --no-turnarounds true \ --junctions.corner-detail 5 \ --junctions.internal-link-detail 5 \ --check-lane-foes.all true \ --output-file net.net.xml--no-turnarounds true:禁止掉头连接。城区路网里掉头通常只允许在特定位置,让 netconvert 自动生成一堆掉头连接会显著改变通行能力。--junctions.corner-detail和--junctions.internal-link-detail:控制路口内部形状点数量。这两个值调大能改善可视化里的转弯轨迹,也会轻微增加计算量,5 到 10 之间比较平衡。--check-lane-foes.all true:强制做冲突检查。这个开着会让转换变慢,但能提前暴露"两条连接互相打架"的问题。--plain-output-prefix:把生成结果回写成明文 XML,这是排查连接问题最关键的一步,因为net.xml里的连接信息是压缩过的,读起来很痛苦。
提示:转换完成后不要立刻开始跑仿真。先用一个极小的需求文件(比如 10 辆车)跑 60 秒,配合 GUI 的"显示车道连接"功能,逐个路口看一遍转弯轨迹。这一步花 20 分钟,能省下后面几天的返工。
2.2 车道数收缩与转向禁行带来的"消失车道"
车道数变化是另一个高频出错点。一条 3 车道的城市道路在路口前变成 2 车道,netconvert 默认会在收缩位置生成一个"消失车道",但它的处理策略和真实标线不一致:现实中通常是提前几百米开始逐步合并,而软件往往在路口前一小段才处理,造成一个假的瓶颈。
处理思路有两条。第一条是显式声明车道连接,用connection补丁文件强制指定哪条车道连向哪条:
<connections> <connection from="east_in" to="east_out" fromLane="0" toLane="0"/> <connection from="east_in" to="east_out" fromLane="1" toLane="1"/> <connection from="east_in" to="north_out" fromLane="2" toLane="0"/> <connection from="east_in" to="south_out" fromLane="2" toLane="1"/> </connections>第二条是在路网源头就把车道数改对,比如在节点处提前设置numLanes,让收缩发生在路段中间而不是路口。我一般优先选第二条,因为补丁文件越多,后期维护成本越高。
转向禁行同理。现实中的"禁止左转"如果不在net.xml里声明,仿真会生成左转车流,这是最常见的路网失真来源之一。禁行的表达方式是在connections里对该方向不写任何连接,而不是写一个禁止标记——netconvert 的逻辑是"没声明就是允许自动生成"。所以如果要做禁行,必须同时用--no-turnarounds配合显式连接表,把路口的所有连接关系完全接管过来。
2.3 让行、优先权与 keep-clear 的显式声明
在没有信号灯的路口,谁让谁完全由路网的优先权决定。netconvert 会通过边类型(priority属性)和车道数推断主次关系,但推断结果经常和实际不符。判断方法是看仿真中支路车流是否直接汇入主路——如果是,说明支路被赋予了过高优先权。
需要关注的两处:
- 边的 priority 属性:主干道通常设为 10 以上,支路设为 1 到 3。这个值直接影响让行判定和连接生成。
- 路口类型
type属性:priority表示无信号让行路口,right_before_left表示全向停让,traffic_light表示信号控制。这几个值写错,整个路口的行为逻辑就完全变了。
还有一个容易被忽略的keep-clear设置。它控制车辆是否允许在路口内停留,影响的是"路口溢出"这一现象的仿真真实性。当你的路网里存在短距离路口串联(比如两个路口之间只有 30 米)时,开启 keep-clear 能避免车辆堵在路口中间造成死锁。参数是--default.junctions.keep-clear true,也可以按路口单独设置。
2.4 路网自查:从明文文件回读到一致性检查
我现在的固定流程是:转换 → 回读明文 → 肉眼扫连接 → 小需求试跑 → 通过后才进入需求生成阶段。回读命令就是 2.1 里那条--plain-output-prefix,生成的文件包括:
plain.nod.xml:节点,用于检查路口类型和坐标plain.edg.xml:边,用于检查车道数、限速、优先级plain.con.xml:连接,用于检查转向逻辑plain.tll.xml:信号配时,用于检查相位是否合理plain.typ.xml:类型定义,用于检查道路分级
这四个文件加起来不到 1000 行,一个中等规模的城区路网 20 分钟能扫完。重点看三类异常:连接数明显偏少的进口道、限速为 0 或异常高的边、以及被意外生成为信号路口的位置。
注意:
--plain-output-prefix生成的明文文件可以直接修改后再用 netconvert 重新编译回 net.xml,这是官方支持的"往返编辑"流程。比起直接改 net.xml,这种方式的改动是可追溯、可复现的。
3. 交通需求从哪来:手写、随机生成与 OD 反推三条路
路网收尾之后就是需求。很多人这一步做得很随意,随便写几十辆车跑一跑,然后拿结果去对比方案。这是最浪费的一步——路网修得再好,需求不对,结论就是空的。需求生成有三种主流做法,适用场景完全不同,下面分别说。
3.1 手写 route 文件:验证逻辑够用,做评估不够
手写需求的最大价值不是评估,而是验证。几十辆车、固定的出发时间、明确的路径,用来检查某个路口的行为是否符合预期,非常高效。结构大致是这样:
<routes> <vType id="car" vClass="passenger" length="4.8" accel="2.6" decel="4.5" sigma="0.5"/> <route id="r_east_west" edges="east_in east_mid west_out"/> <flow id="f_main" type="car" route="r_east_west" begin="0" end="3600" vehsPerHour="600"/> </routes>这里有几个细节值得强调。vehsPerHour、period、probability三个属性是互斥的,只能用一个。vehsPerHour是固定间隔发车,period是它的倒数形式(周期秒数),probability是每一步按概率决定是否发车,后者产生的到达分布更接近随机流。做对比实验我一般用vehsPerHour,因为不同方案使用的需求完全一致,便于控制变量;做鲁棒性测试则用probability配合不同随机种子。
还有一个很多人不知道的用法:SUMO 支持直接在需求文件里写trip而不用提前算好路径:
<trip id="t1" type="car" depart="12.5" from="east_in" to="west_out"/>仿真运行时会自动为其算路。这在小规模验证阶段特别方便,缺点是大规模运行时每次动态算路会拖慢速度,而且路径不一定符合真实车流的分布。
3.2 randomTrips.py 参数组合与三个常见误用
randomTrips.py是 SUMO 工具包里最好用的脚本之一,位于SUMO_HOME/tools/目录。基本调用:
python $SUMO_HOME/tools/randomTrips.py \ -n net.net.xml \ -r routes.rou.xml \ -b 0 -e 3600 \ -p 1.2 \ --fringe-factor 10 \ --min-distance 300 \ --trip-attributes 'type="car" departLane="best" departSpeed="max"' \ --validate参数说明和踩坑点:
-p是平均发车间隔(秒),值越小流量越大。注意它是"平均间隔",实际出发时间带随机扰动,不是等间隔。--fringe-factor控制起终点落在路网边缘的倾向。默认值 1,做穿越型交通时通常要调到 5 到 20,否则大量车辆会在路网内部绕短圈。--min-distance过滤掉过短的行程。默认 0 会导致一堆车只走三四十米就到达,把平均行程时间算得极低。这个参数不设,数据基本没法用。--validate会检查生成的行程是否能走通,建议一直开着。--seed固定随机种子,做对比实验时必须固定。
三个高频误用:第一,只给-o不给-r,结果是得到一堆 trip 文件却以为已经生成路径;第二,忘记指定--trip-attributes,所有车用默认车型和默认车道选择策略,不符合真实驾驶行为;第三,流量算错——很多人把-p当成"每秒发车数",实际它是"每辆车平均间隔秒数",搞反了会差几十倍。
3.3 od2trips 与 duarouter:从 OD 矩阵到可执行路径
如果你手上有实际调查得到的 OD 矩阵(比如小区之间的出行量),randomTrips 就不适用了,应该走od2trips加duarouter这条链路。
第一步是定义交通小区(TAZ),用节点的形式列出每个小区包含哪些边:
<tazs> <taz id="zone_A" edges="east_in_1 east_in_2"/> <taz id="zone_B" edges="west_out_1 west_out_2"/> </tazs>第二步准备 OD 矩阵,格式是起点、终点、车辆数三列,用制表符分隔:
zone_A zone_B 600 zone_B zone_A 540 zone_A zone_A 120第三步转换:
python $SUMO_HOME/tools/od2trips.py \ -n net.net.xml \ --taz-files zones.taz.xml \ -d od_matrix.txt \ -o trips.trips.xml \ --begin 0 --end 3600 \ --spread.uniform true--spread.uniform表示把区间内的车辆均匀铺开,不设的话所有车会在begin时刻集中出发,造成一个假的冲击流量。转换完之后还要用 duarouter 算路:
duarouter -n net.net.xml -r trips.trips.xml -o routes.rou.xml \ --ignore-errors true \ --repair true \ --weights.random-factor 1.5 \ --seed 42--ignore-errors和--repair用来处理那些起终点本身无法连通的行程,不加的话一个坏行程会让整个转换中断。--weights.random-factor给路段权重加随机扰动,让不同车辆在同一 OD 下选择略有差异的路径,比所有车走同一条路更接近真实。
3.4 vType 不定制,等于用同一款车跑完整个城市
最后一个被严重低估的环节是车型定义。默认车型的加速度、车头时距、跟车模型系数都是通用值,用来做方案比选时问题不大,但如果你的场景涉及公交、货车、非机动车混行,就必须定制。
我常用的几个关键属性:
| 属性 | 含义 | 经验取值 |
|---|---|---|
length | 车长(米) | 小汽车 4.5-5.0,公交 12,货车 8-16 |
accel/decel | 加减速度(m/s²) | 小汽车 2.6/4.5,公交 1.2/3.0 |
sigma | 驾驶不完美度 | 0.5 默认,越小越"理想" |
speedFactor | 期望速度系数 | 正态分布 normc(1,0.1,0.2,2) |
minGap | 停车时最小间距 | 小汽车 2.5,公交 3.0 |
carFollowModel | 跟车模型 | Krauss 通用,IDM 更平滑 |
lcStrategic | 换道意愿 | 默认 1,拥堵场景可调高 |
speedFactor用分布而不是固定值,这一点很关键。如果所有车的期望速度完全一样,仿真里会出现非常整齐的车队,通行能力被高估。用正态分布让每辆车有自己的速度偏好,仿真结果会明显更接近实测。
4. 信号控制与仿真时序:让结果失真的那些细节
需求配好之后,很多人就直接跑了,忽略了两个同样重要的参数组:信号配时和仿真时序。这两块的错误不会导致报错,但会让结果系统性偏离。
4.1 自动生成配时方案的边界在哪里
netconvert 可以通过--tls.guess true自动猜测哪些路口需要信号灯,并通过--tls.guess-signals识别路网中已有的信号标记。自动生成的配时方案特点是:相位数量最少、绿信比按车道数大致分配、周期固定。对于一个标准十字路口,这套方案能跑,但绝对不适合做信号优化对比——因为它本身就没有优化过。
需要干预的情况有三种:路口有多个转向阶段需要保护左转(必须手工拆相位)、路口是畸形交叉口(自动相位可能产生冲突)、以及需要设置协调控制(绿波带必须手工配 offset)。
手工配时的基本结构:
<additional> <tlLogic id="J1" type="static" programID="custom" offset="0"> <phase duration="30" state="GGGrrrGGGrrr"/> <phase duration="3" state="yyyrrryyyrrr"/> <phase duration="2" state="rrrrrrrrrrrr"/> <phase duration="25" state="rrrGGGrrrGGG"/> <phase duration="3" state="rrryyyrrryyy"/> <phase duration="2" state="rrrrrrrrrrrr"/> </tlLogic> </additional>state字符串的每一位对应一个受控连接(linkIndex),顺序由路网决定,不能随便猜。正确做法是从plain.tll.xml里读出每个 linkIndex 对应的连接,再按顺序写字符串。这一步写错的话,相位和实际车流完全对不上,但仿真不会报错,非常危险。
4.2 黄灯、全红与清空时间:state 字符串里每个字符的含义
state 字符串里不只有 G、y、r 三种,实际支持的有九种,理解它们才能写出正确的相位:
| 字符 | 含义 |
|---|---|
G | 绿灯,有优先通行权 |
g | 绿灯,需让行(保护左转未受保护时常用) |
y | 黄灯 |
Y | 黄灯,有优先通行权 |
r | 红灯 |
u | 红灯加制动提示(用于清空) |
o | 黄灯加制动提示 |
O | 黄灯,优先通行加制动提示 |
s | 停止让行(用于特殊控制) |
黄灯时长的经验值是按 3 秒设,但如果路口限速高、进口道宽,需要用清空时间公式校验:黄灯时长 ≈ 反应时间(1 秒)加 车速除以二倍减速度。限速 60 km/h(约 16.7 m/s)、减速度取 3 m/s² 时,清空时间约 1 + 16.7/6 ≈ 3.8 秒。这时候还按 3 秒设,就会出现车辆在红灯时仍在路口内的情况,仿真的路口通行能力会被高估。
全红时间按路口对角线长度除以低速(约 10 m/s)估算。30 米的交叉口大约需要 3 秒,不要为了追求通行能力把全红设成 0,那样路口内的冲突会以"车辆互相穿过"的形式表现出来,统计上完全失真。
4.3 步长、种子与预热时间
仿真步长默认 1 秒,这个值对城市交通够用,但涉及加减速细节、行人过街、非机动车道行为时,0.1 到 0.5 秒的步长更合适。代价是计算时间成倍增长,1 小时仿真 0.1 秒步长意味着 36000 步,比 1 秒步长慢十倍。
sumo -c sim.sumocfg \ --step-length 0.5 \ --seed 42 \ --begin 0 --end 7200 \ --no-step-log true \ --duration-log.statistics true随机种子必须固定,这是做对比实验的前提。默认种子是 23423,但很多人不知道这一点,做方案对比时没有显式指定,结果两个方案的差异里混进了随机性。
预热时间这件事,我的做法是把仿真时长设成"预热时长加分析时长",然后在输出统计里用时间窗口过滤掉前段。比如预热 600 秒、分析 3600 秒,总时长设 4200 秒,edgeData的begin设为 600。这样比用负数起始时间更稳妥,因为很多后处理脚本对负时间不友好。
4.4 车辆插不进去:waiting 与 teleport 的排查顺序
仿真跑完之后,如果发现waiting数量一直很高,或者日志里出现 teleport 警告,按下面的顺序排查:
- 看出发时刻的流量峰值。如果所有车都在
begin时刻出发,入口瞬间过载。用--spread.uniform或按时间分段铺开。 - 看入口车道数和发车密度是否匹配。一条入口车道理论上每秒最多通过约 0.5 辆车(按 1800 辆/小时算),如果设置的发车强度超过这个值,插不进去是必然的。
- 看 departSpeed 设置。
departSpeed="max"要求插入位置有足够的安全间隙,密集车流下会大幅降低插入成功率。改成departSpeed="desired"或适当降低速度更容易插入。 - 看 departLane 策略。
departLane="best"会选最优车道,但在多车道入口上可能导致某条车道过载,可以试free或random。 - 最后看 teleport 参数。默认等待 300 秒后车辆会被"传送",这个机制本来是防止网格锁死,但会把真实的拥堵数据抹掉。做拥堵分析时我一般把它调大(
--time-to-teleport 900)甚至关闭,同时接受仿真变慢的代价。
提示:
statistic-output里的<teleports>统计必须每次跑完都看一眼。teleport 数量超过总车辆的 1%,基本可以判断这份结果不适合做拥堵分析。
5. 输出配置:把四类数据一次配明白
第三步最后一个技术环节是输出。这一步做得好不好,直接决定后面能不能出结论。我的建议是完全放弃命令行堆参数的做法,改用配置文件加 additional 文件的方式,好处是配置可复用、可版本管理、可批量实验。
5.1 用 additional 文件组织输出,而不是堆命令行
完整配置分成三层:主配置.sumocfg、路网输入、额外的信号与输出定义。
<configuration> <input> <net-file value="net.net.xml"/> <route-files value="routes.rou.xml"/> <additional-files value="tls.add.xml,output.add.xml"/> </input> <time> <begin value="0"/> <end value="4200"/> <step-length value="1"/> </time> <processing> <time-to-teleport value="900"/> <ignore-route-errors value="true"/> </processing> <report> <no-step-log value="true"/> <duration-log.statistics value="true"/> </report> </configuration>而output.add.xml里集中放周期统计和边数据定义:
<additional> <edgeData id="ed_hour" file="edgeData.xml" begin="600" end="4200" excludeEmpty="true" withInternal="true"/> <edgeData id="ed_5min" file="edgeData_300.xml" period="300" begin="600" end="4200" excludeEmpty="true"/> </additional>三层分离的好处是:换需求只改route-files,换输出策略只改 additional,做批量实验时用命令行覆盖单个参数即可。SUMO 的参数优先级是命令行大于配置文件,所以批量实验完全不需要改文件。
5.2 四类输出文件的字段与适用场景
| 输出 | 开启方式 | 粒度 | 主要用途 |
|---|---|---|---|
| summary | --summary-output | 每仿真步 | 宏观趋势、在网车辆数、平均速度随时间变化 |
| tripinfo | --tripinfo-output | 每辆车到达时 | 行程时间、延误、停车次数,方案对比核心数据 |
| edgeData | additional 定义 | 每统计周期 | 断面流量、平均速度、行驶时间、排队,做路段分析 |
| fcd | --fcd-output | 每仿真步每车 | 轨迹数据,用于可视化动画、微观行为分析 |
tripinfo的几个字段必须搞清楚含义,因为报告里最常引用的就是它:
| 字段 | 含义 | 使用注意 |
|---|---|---|
duration | 从出发到到达的总时间 | 包含等待时间 |
timeLoss | 相对自由流行驶的损失时间 | 更接近"延误",报告优选 |
waitingTime | 速度低于阈值的累计时间 | 不等于延误,只是低速时长 |
departDelay | 实际出发与计划出发的差值 | 大于 0 说明插入受阻 |
routeLength | 实际行驶距离 | 可用于校验路径是否异常 |
vaporized | 是否被中途移除 | 出现该标记的车辆要单独剔除 |
做方案对比时,我会同时输出tripinfo和edgeData。前者给全局指标,后者给路段级指标,两套数据相互印证。如果发现tripinfo显示延误下降但edgeData显示某几个路段排队变长,说明改善是局部的,不能一概而论。
5.3 分段统计与时间窗口
edgeData的period属性决定统计区间长度。做拥堵演化分析时用 300 秒一个区间,做全天流量统计时用 3600 秒。有一个细节很容易被忽略:excludeEmpty="true"会跳过没有车辆通过的路段,如果不设,输出文件里会出现大量零值记录,后处理时容易把"没数据"当成"流量为零"。
fcd文件体积非常大,1 小时、每小时 2000 辆车的仿真,输出可能有几百 MB。用的时候一定要限制时间窗口,或者只对特定车辆开启:
<fcd-export file="fcd.xml" begin="1800" end="2400" vTypes="bus"/>只导出公交车辆的轨迹,文件能小两个数量级,分析公交专用道的效果反而更清楚。
5.4 后处理:把 XML 变成能看懂的指标
SUMO 输出的都是 XML,直接看很痛苦。我一般用 pandas 加 ElementTree 两步处理:先把 XML 转成 DataFrame,再做分组聚合。核心思路如下:
import xml.etree.ElementTree as ET import pandas as pd tree = ET.parse("tripinfo.xml") rows = [e.attrib for e in tree.getroot()] df = pd.DataFrame(rows).astype({"duration": float, "timeLoss": float, "depart": float, "waitingTime": float}) df = df[df["vaporized"].isna()] if "vaporized" in df else df df["period"] = pd.cut(df["depart"], bins=range(600, 4300, 600)) summary = df.groupby("period").agg( veh=("duration", "size"), avg_duration=("duration", "mean"), avg_timeloss=("timeLoss", "mean"))注意vaporized的过滤逻辑——被中途移除的车辆没有正常到达,留在统计里会拉低平均行程时间。这个细节不做,数据就有系统性偏差。
另外提醒一句:edgeData里同时有traveltime和speed两个字段,前者是整个统计周期内通过该路段车辆的平均行驶时间,后者是周期内的平均速度。做拥堵分析用traveltime更稳定,因为它不依赖采样时刻;做速度分布分析才用speed。
6. TraCI 接入:让外部程序真正控制仿真
如果你只需要跑固定场景出数据,到第 5 节就可以收工了。但大多数实际项目需要仿真与外部逻辑联动:动态信号控制、车辆路径重规划、实时数据注入、外部算法对比测试。这时候必须用 TraCI。
6.1 remote-port 与 traci.start 的握手
TraCI 的工作方式是把 SUMO 当成一个服务进程启动,外部程序通过 TCP 连接发指令。启动方式有两种,我推荐显式指定端口:
sumo -c sim.sumocfg --remote-port 8813 --num-clients 1然后在 Python 里连接:
import traci traci.start(["sumo", "-c", "sim.sumocfg", "--remote-port", "8813", "--no-step-log", "true"]) for step in range(3600): traci.simulationStep() t = traci.simulation.getTime() if step % 300 == 0: print(t, traci.simulation.getLoadedIDList().__len__(), traci.simulation.getArrivedNumber()) traci.close()--num-clients在多进程并行控制时需要显式指定。端口被占用是新手最常遇到的错误,排查方法就是换端口,不要浪费时间在环境上。
6.2 simulationStep 与 step 的语义差别
这是 TraCI 里最容易被误解的一对调用。simulationStep()让仿真推进一个步长(默认 1 秒),同时把该步内的订阅数据刷新回来。而simulationStep(target_time)带参数时,会一直推进到目标时间点,中间的所有步结果被跳过——如果你在中间需要做控制决策,用带参数的版本会丢失大量决策机会。
所以控制类应用应该用无参版本逐 step 循环,批量数据采集才用带目标时间的版本。这一点搞错的话,效果是控制逻辑"反应迟钝",因为你实际上只在每 N 秒有机会干预一次。
另一个关键点:TraCI 的所有查询返回的都是上一步结束时刻的状态。也就是说在step循环里,traci.simulation.getTime()返回的是刚结束的那一步的时间,不是即将开始的那一步。做控制时如果按当前时间做判断、按下一时刻执行,逻辑一定要理清,否则会出现"用未来数据做当前决策"的错位。
6.3 订阅机制:为什么循环里不要逐车取值
很多人写 TraCI 代码时习惯这样:
for veh in traci.vehicle.getIDList(): speed = traci.vehicle.getSpeed(veh) pos = traci.vehicle.getPosition(veh)每一次getSpeed都是一次 TCP 往返。上千辆车、几千步循环下来,通信开销会成为绝对瓶颈,仿真时间被通信拖长好几倍。正确做法是用订阅机制一次性拿回所有需要的数据:
import traci.constants as tc traci.vehicle.subscribe(veh_id, (tc.VAR_SPEED, tc.VAR_POSITION, tc.VAR_ROAD_ID, tc.VAR_LANE_INDEX)) results = traci.vehicle.getSubscriptionResults(veh_id)订阅之后,每次simulationStep()会自动把订阅变量刷新到本地缓存,取值不再产生网络往返。实测下来,千车规模下订阅方式比逐车查询快一个数量级。
如果需要按时间窗口订阅(比如只在某段时间采集),可以给subscribe传begin和end参数,超出窗口后自动停止刷新,避免无谓的开销。
6.4 联调最容易卡住的三个点
第一,加载顺序问题。TraCI 的模块加载有隐含依赖,比如用了traci.vehicle再调traci.simulation有时会触发未初始化的错误。稳妥做法是在脚本开头就 import 需要用到的所有模块,或者统一使用traci.start之后的第一次调用做一次完整探测。
第二,性能瓶颈在 Python 而不在 SUMO。用 libsumo 替代 traci 可以绕开 TCP,把 SUMO 作为库直接加载,速度提升明显,代价是不支持分布式和部分高级功能。如果做单机大批量实验,libsumo 是更好的选择:
import libsumo as traci traci.start(["sumo", "-c", "sim.sumocfg"])代码几乎不用改,只需替换导入语句,这也是我建议把 TraCI 调用集中封装的原因。
第三,异常退出导致进程残留。脚本报错时如果没执行traci.close(),SUMO 进程会一直占着端口,下一次连接直接失败。稳妥写法是用try/finally包住主循环,或者用with语法。踩过几次之后,我现在所有脚本都强制加 finally 关闭。
7. 批量实验与性能调优:跑得慢、跑不动、结果诡异
到了这一步,单个场景已经能跑通了,剩下的问题是规模化和稳定性。这一节讲三件在真实项目里最耗时间的事。
7.1 性能瓶颈定位:哪些设置最吃时间
SUMO 的性能问题通常来自四个地方,按影响从大到小排:
| 因素 | 影响 | 优化方向 |
|---|---|---|
| 输出文件过多、频率过高 | 极大 | 只保留必要输出,缩短时间窗口 |
| 仿真步长过小 | 大 | 城市交通 0.5-1 秒足够 |
| 动态算路(trip 未预处理) | 中到大 | 提前用 duarouter 算好路径 |
| 路网规模与车道数 | 中 | 剔除分析范围外的路网 |
一个 1000 车规模、1 小时时长、包含 fcd 输出的仿真,可能比不开 fcd 慢五到十倍。做参数标定时批量跑几百次,这个差异就是几小时和几天的区别。
定位方法很简单:先用--no-step-log true关闭逐步输出,跑一次记录墙钟时间;然后逐个开启输出,观察时间变化。我一般的做法是保留tripinfo和edgeData(这两个是必需品),其他的按需临时开。
7.2 批量实验:配置文件的复用与参数覆盖
做方案对比时,几十个组合是常态。我的做法是写一个 shell 脚本,固定配置文件,用命令行覆盖关键参数,用--output-prefix隔离结果:
for seed in 42 43 44; do for cfg in base caseA caseB; do sumo -c ${cfg}.sumocfg \ --seed ${seed} \ --output-prefix ${cfg}_s${seed}_ \ --no-step-log true done done--output-prefix会把所有输出文件加上统一前缀,不需要为每个组合单独改配置文件,后处理时按前缀匹配即可。这个参数是批量实验效率提升最明显的一个,很多人不知道它的存在。
另一个技巧是用--save-template生成一份包含全部可调参数和默认值的配置文件,需要确认某个参数的官方名称和取值范围时,直接在这份文件里搜,比翻文档快得多。
7.3 警告日志怎么读
SUMO 的警告信息量很大,但有价值的主要是这几类:
- 关于车辆插入失败的:出现频率高说明需求配置或路网入口有问题。
- 关于路径未找到的:说明需求里存在不连通的起终点,需要修 OD 或者修路网。
- 关于 teleport 的:说明局部出现死锁或长时间等待。
- 关于车型重复定义的:多个文件里定义了同名 vType,SUMO 会用第一个并忽略后面的,容易造成参数不生效。
看日志的方法不是一行行读,而是先统计各类警告的数量,再挑数量最多的那类深入。命令上可以用2>warn.log把标准错误重定向出去,然后用grep -c分类计数。这一步做熟了,一份日志三十秒就能定位问题。
7.4 复现性:同一份配置跑两次结果不同的原因
最后说一个经常被质疑的点。同一份配置,两次跑出来的平均行程时间不一样,原因可能有四个:
第一,随机种子没固定。默认种子虽然是固定的,但如果配置里用了--random特性或者从系统时间取种子,结果就会变。
第二,用了动态算路但没固定算路算法的随机性。duarouter 的权重扰动和算路顺序都可能带随机性,需要在算路阶段就固定种子。
第三,输出时间窗口依赖的预热未真正完成。如果预热时间不够,路网在第 600 秒时还没进入稳定状态,那么第 600 秒到 4200 秒的统计里混入了非稳态数据,两次的差异会被放大。
第四,统计口径本身包含了未完成行程的车辆。用过滤后的tripinfo而不是全部记录,能显著降低这种波动。
我的建议是做方案对比时,每个方案至少跑三个不同种子,取平均值,并且把种子间的标准差一并写进报告。标准差太大(比如超过均值的 10%)说明当前需求配置本身的随机性过高,需要先增加流量或者延长统计时长,让结果收敛之后再比较方案。
关于 SUMO 仿真环境第三阶段,我个人最大的体会是:真正的技术含量不在写配置文件,而在于对每一个参数的"为什么"有清楚的认识。一个--min-distance没设,可能让整个项目的行程时间指标偏低 30%;一个state字符串顺序写错,可能让信号优化方案的结论完全反转。这些坑软件都不会提示你,只能靠提前知道。另外一个实用建议是,把每次实验的完整配置、命令、种子和输出目录名记录在一个表格里,看似麻烦,等一周后有人问"那个右转专用道的方案是哪个配置跑出来的"时,你会庆幸自己记了。