
说实话做机会网络DTN仿真的同学应该都有过这种经历论文里需要用真实移动轨迹来验证路由协议但机会网络仿真器ONE默认自带的移动模型基本都是随机Walk、RandomWaypoint这一类跑出来的节点路径看着就假评审一质疑就站不住脚。第一次接触ExternalMovement这个移动模型时我以为就是随便改个配置结果光是把外部移动数据导进去这一步就折腾了我一整天。这篇文章我就把“初识机会网络仿真器ONE中ExternalMovement移动模型”这整个过程掰开揉碎讲清楚从为什么需要它、文件格式长什么样到怎么把真实GPS轨迹转换成它认得的格式再到实际跑通仿真最后再把我踩过的坑和排查思路一并列出来。这篇内容适合正在用ONE做机会网络、延迟容忍网络仿真的研究生、工程师也适合刚入门想搞清楚“移动模型到底怎么替换”的读者。读完你不仅能跑通ExternalMovement还能自己准备数据文件避免那些文档里根本不会写的坑。1. 为什么非要用ExternalMovement内置移动模型的尴尬1.1 ONE默认的移动模型到底差在哪机会网络仿真器ONE本身内置了好几种移动模型最常用的就是RandomWaypoint此外还有RandomDirection、MapBasedMovement等。RandomWaypoint的逻辑很简单节点随机选一个目标点以某个速度走过去到了再停一会然后接着选下一个目标点。这种模型在早期DTN研究中确实帮了大忙因为它足够简单、可复现做路由协议性能对比时很方便。但问题也恰恰出在这个“随机”上。拿城市车载场景举例真实车辆是沿着道路走的路口要减速红灯要停拥堵路段会密集接触不同路段的车流密度差异很大。RandomWaypoint完全不管这些节点可以穿墙而过、横跨广场移动轨迹没有任何地理约束和空间相关性。你拿这种轨迹去跑消息投递率、端到端时延、开销比这些指标得出的是不是合理结果就很难说。审稿人看到你用RandomWaypoint做车载机会网络仿真十有八九会来一句“请说明移动模型与实际场景的匹配性”这句话基本就戳中了软肋。另一个更隐蔽的问题是时空关联性。真实移动数据里节点今天的轨迹和昨天的轨迹往往有相似性人群有通勤规律、车辆有路径偏好这种时空规律会直接决定节点间的相遇模式。机会网络的本质就是靠节点移动产生接触机会相遇模式一旦失真整个仿真链条的结果都不可信。内置模型生成的接触事件分布往往太均匀缺少热点和间歇性这会让你的路由协议在评估时看不出真实差距。1.2 ExternalMovement带来的核心改变ExternalMovement就是一个专门解决“把真实轨迹数据引入仿真”的移动模型。它本身不生成移动行为而是播放一个外部文本文件里预先定义好的“时间-节点-坐标”序列。换句话说内置模型是现场编故事ExternalMovement是按剧本演戏。只要你的剧本足够真实仿真里的节点运动就足够真实后续路由协议评估的可信度也就上来了。这一点对做真实数据集驱动仿真的同学尤其重要。你可以拿公开的出租车GPS轨迹、手机信令位置数据甚至自己采集的行人轨迹通过坐标转换和处理灌进ONE里跑仿真。这样你在论文里就能写出“仿真基于XX真实轨迹数据集”审稿人看完会觉得你的实验结果有实际应用支撑而不是停留在理想化的随机模型上。我在实际使用中还发现一个好处ExternalMovement文件是纯文本的每一行就是一条位置记录很容易用Python或者其他语言来生成也很容易对数据进行切片、重采样、拼接做实验时非常灵活。你可以把一段一小时的真实轨迹切成好几种场景来测试只需要处理文件头尾就行不需要改动仿真器的任何代码。2. ExternalMovement原理与文件格式先搞懂它怎么“读剧本”2.1 配置参数详解在ONE里切换移动模型的入口是Group.movementModel把它指向movement.ExternalMovement就行同时还要设置外部文件路径。下面是一份最简配置可以直接写到你的场景配置文件里Scenario.name external_movement_test Scenario.endTime 1800 Scenario.updateInterval 1 Group.movementModel movement.ExternalMovement MovementModel.externalFile movements/sample_external.txt Group.nrofHosts 5 Group.router EpidemicRouter Group.interface1 SimpleBroadcastInterface这里有几个点要提醒一下MovementModel.externalFile是全局参数不是每个Group单独一个。也就是说哪怕你有多个Group它们读的也是同一个外部文件这是它的一个明显限制后面我会细讲。Group.nrofHosts必须和外部文件里的节点ID数量对应我习惯先数清楚文件里有多少个节点再回来填这个参数。路径是相对于ONE主目录的比如你把数据文件放在movements/sample_external.txt配置里就这么写大小写要特别注意我之前因为文件名首字母大写对不上卡了好一阵子。2.2 外部数据文件的格式规范ExternalMovement读取的文件格式核心就是四列时间、节点ID、X坐标、Y坐标。每行一条记录字段之间用空格或者制表符分隔我习惯用空格。一个标准的文件片段长这样0 0 1500.0 2000.0 0 1 1800.0 2100.0 0 2 2200.0 1800.0 0 3 1200.0 1500.0 0 4 2500.0 1900.0 10 0 1520.0 2010.0 10 1 1810.0 2110.0 10 2 2210.5 1805.0 10 3 1205.0 1500.5 10 4 2510.0 1905.0 20 0 1540.0 2020.0 ...字段的含义和细节如下表所示字段类型说明时间整数或浮点数以秒为单位建议从0开始按升序排列节点ID整数从0开始编号不能有负数不能有缺失X坐标浮点数仿真环境中的X坐标单位与ONE世界坐标一致Y坐标浮点数仿真环境中的Y坐标这个格式里有几个隐性要求是踩过坑才明白的第一时间戳必须分层分组。同一时间戳下要把所有节点的位置都列完然后才进入下一个时间戳。比如时间0下面有节点0到4的5行时间10下面又有节点0到4的5行。如果某个时间戳下某个节点缺失这个节点在后续时间就会停留在最后一个已知位置很容易导致轨迹断层。第二时间戳可以不等间隔但建议使用固定间隔。原因在于ExternalMovement做的是“跳跃式更新”节点在两个时间戳之间保持位置不变到下一个时间戳瞬间跳到新位置中间不会做线性插值。如果时间间隔太大比如60秒一跳节点会像瞬移一样接触时长、连接断链等指标都会失真。我建议用1秒到10秒的采样间隔。第三时间戳一旦开始不能回退。整个文件必须严格按时间升序排列如果某一行的时间小于前面一行会直接导致数据解析错乱节点位置会乱跳。生成文件前用sort命令或者Python排个序能省很多麻烦。2.3 坐标系统与仿真世界大小ONE仿真环境默认世界尺寸是4500乘3400单位是米你可以通过Scenario.worldSize调整。ExternalMovement不会检查坐标是否落在世界范围内它就是一个“给坐标就赋值”的直性子坐标超出世界尺寸完全不影响程序运行但你在可视化界面里会看到节点飞到地图外面去这在调试时非常容易误导。所以外部文件的坐标必须要和仿真世界尺寸匹配。如果你的原始数据是GPS经纬度不能直接写进去得先做坐标转换和缩放把经纬度映射到4500乘3400的范围内。这一步通常在准备数据文件时用Python完成我后面会给出一个实际可用的转换思路。还有一个小细节ExternalMovement对仿真开始时刻的处理比较严格。文件第一行时间戳如果是0那没问题仿真启动时所有节点位置就是这批记录如果第一行时间戳大于0仿真开始的瞬间节点会在一个默认位置通常是0,0然后到第一个时间戳才跳到正确位置。这会在开局制造一些假接触影响投递率统计。最稳妥的做法是把所有时间戳整体平移让第一条记录从0开始。3. 实操从零准备外部移动数据并跑通仿真3.1 原始轨迹数据从哪来准备外部移动数据的第一步是弄到原始轨迹。如果你做的是论文实验推荐使用公开数据集比如Cabspotting旧金山出租车轨迹、T-Drive北京出租车轨迹等。如果你只是想跑通流程完全可以自己造一份测试数据比如用Python生成几条带速度变化的模拟轨迹先验证格式和配置没毛病再换真实数据。我自己第一次试验时用的是自家车上的GPS记录仪导出的轨迹字段很多有经纬度、海拔、速度、时间。当时想着哪怕数据量不大只要是真实轨迹就有说服力。所以我下面的转换脚本也针对“经纬度时间戳”这种常见格式来写。3.2 坐标转换与缩放把经纬度变成仿真坐标真实GPS数据都是经纬度要把它们变成ONE世界坐标先要投影到平面坐标。常用的做法是用等距圆柱投影或者简单Mercator投影。这里我提供一个非常直接的Python函数用来把经纬度转成以某个参考点为原点的米制坐标import math R 6371000.0 def lonlat_to_meter(lon, lat, ref_lon, ref_lat): x R * math.radians(lon - ref_lon) * math.cos(math.radians(ref_lat)) y R * math.radians(lat - ref_lat) return x, y这个函数的原理是以参考点通常取轨迹数据的中心点或起点为原点经度差乘以地球半径再乘以纬度余弦得到东西方向距离纬度差乘以地球半径得到南北方向距离。得到的x、y单位是米已经接近于真实水平距离。接下来要缩放和平移让轨迹落进ONE世界坐标系。我通常保留10个单位左右的边界不做满幅贴边这样节点不至于贴在世界边缘def normalize_track(points, world_width4500.0, world_height3400.0): xs [p[0] for p in points] ys [p[1] for p in points] min_x, max_x min(xs), max(xs) min_y, max_y min(ys), max(ys) sx (world_width - 20) / (max_x - min_x 1e-9) sy (world_height - 20) / (max_y - min_y 1e-9) return [ (10 (x - min_x) * sx, 10 (y - min_y) * sy) for x, y in points ]这里加了一个1e-9防止轨迹是一个点时除数为零。缩放后所有坐标都落在10到4510或10到3410的范围内既不超过世界边界也不会贴着墙跑。3.3 生成ExternalMovement格式文件的完整脚本有了坐标转换函数下一步就是把原始轨迹整理成ExternalMovement格式。我写了一个比较通用的脚本骨架逻辑是读取一个CSV文件里面至少有三列时间戳、经度、纬度每条记录可能有多条轨迹轨迹之间用节点ID区分。脚本先按时间戳升序排序然后把每个时间戳下所有节点的位置按节点ID顺序依次输出。import csv import math def convert_to_external(input_csv, output_txt): # 读取原始数据并编号节点 records {} # (node_id) - list of (time, lon, lat) with open(input_csv, r) as f: reader csv.DictReader(f) for row in reader: nid int(row[node_id]) records.setdefault(nid, []).append(( float(row[timestamp]), float(row[longitude]), float(row[latitude]) )) # 确定参考点用所有数据的均值 all_lons [r[1] for rec in records.values() for r in rec] all_lats [r[2] for rec in records.values() for r in rec] ref_lon sum(all_lons) / len(all_lons) ref_lat sum(all_lats) / len(all_lats) # 逐节点转换坐标并记录每个时间戳的最早时间 node_points {} # nid - list of (time, x, y) min_time float(inf) for nid, rec in records.items(): node_points[nid] [] for t, lon, lat in sorted(rec, keylambda x: x[0]): x, y lonlat_to_meter(lon, lat, ref_lon, ref_lat) node_points[nid].append((t, x, y)) min_time min(min_time, t) # 把时间平移到从0开始 for nid in node_points: node_points[nid] [(t - min_time, x, y) for t, x, y in node_points[nid]] # 求出所有时间戳的并集 all_times sorted({t for nid in node_points for t, _, _ in node_points[nid]}) # 每个时间戳按节点ID输出 with open(output_txt, w) as f: for t in all_times: for nid in sorted(node_points.keys()): pts node_points[nid] # 取当前时间戳或之前最近的记录 best None for pt in pts: if pt[0] t: best pt else: break if best is None: best pts[0] f.write(f{int(t)} {nid} {best[1]:.2f} {best[2]:.2f}\n)这段脚本的逻辑并不复杂但它解决了一个实际问题不同节点的数据采样时刻往往不一样ExternalMovement要求同一个时间戳下所有节点都有记录。我用“取之前最近的一条记录”的方式补齐缺失点保证每个时间戳下节点数量是完整的。如果你自己写脚本这个“按时间对齐”的步骤一定不能省略。3.4 修改配置并运行仿真数据文件生成后我把它保存为movements/sample_external.txt然后在ONE的配置里做最小化修改。一个完整可运行的配置可以参考前面第2.1节的那段我再补充几个路由和报告相关的选项方便你确认节点确实按照外部轨迹移动了Scenario.name external_movement_demo Scenario.endTime 3600 Scenario.updateInterval 1 Scenario.worldSize 4500, 3400 Group.movementModel movement.ExternalMovement MovementModel.externalFile movements/sample_external.txt Group.nrofHosts 10 Group.router EpidemicRouter Group.interface1 SimpleBroadcastInterface Group.bufferSize 5M Report.movement true Report.contact true运行方式有两种。一种是在ONE目录下直接执行./one.sh -b default_settings.txt跑批处理模式适合批量实验另一种是用IDE打开ONE项目把配置文件名作为参数传入core.DTNSim类启动适合边看可视化边调试。我建议第一次跑的时候用GUI模式把界面打开观察节点是不是严格按照文件中的轨迹在移动、有没有异常跳变。等确认轨迹没问题再关掉GUI去跑批量实验。运行后如果一切正常你会看到输出目录里多出movement_report.txt和contact_report.txt这类报表里面记录了节点每个时间段的位置和节点间的接触事件。这些报表就是后续分析路由协议性能的基础数据。4. 实操中的坑很多错误我替你踩过了4.1 文件路径找不到或读取失败最常见的问题就是外部文件路径配置错误。ONE对路径大小写敏感而且基于相对路径。你明明把文件放在了movements/sample_external.txt配置里写movements/sample_external.txt但如果文件名的某个字母大小写不一致就会抛出类似“FileNotFoundException”的异常仿真直接终止。排查思路很简单先确认文件确实存在可以用命令ls -l movements/sample_external.txt看一眼。再对比配置里的路径和实际路径一个字母一个字母地核对。如果你用的是Windows系统注意ONE里配置文件的路径分隔符最好用正斜杠/不要用反斜杠\。还可以开启ExternalMovement的调试开关。在配置文件里加上ExternalMovement.debug true启动时控制台会打印出读取的文件路径和解析到的记录条数。如果打印出来的路径跟你预期的不一样那说明配置加载有问题直接顺着这个路径去排查。4.2 时间范围与仿真时长不匹配导致节点“卡死”ExternalMovement是按外部文件的时间戳来更新节点位置的如果Scenario.endTime设置得比文件中最后一个时间戳还大那么仿真时间超过文件末尾之后所有节点都会停留在最后一个位置。这意味着模拟后期节点之间几乎静止接触模式完全失真。更隐蔽的情况是你只看平均投递率后期静止期把平均值拉低了但你并不知道原因。我的做法是写一个小脚本统计外部文件的最大时间戳然后确保Scenario.endTime不超过这个值通常我会让endTime等于最大时间戳或者干脆把轨迹数据切到想要的仿真时长再去做对齐。数据切片也是ExternalMovement常用的一个操作比如你有一段24小时的真实轨迹想仿真其中早高峰1小时就把数据裁剪到那1小时再用第一个时间戳做归零平移。4.3 节点数量不一致导致位置错乱节点数量不匹配是很隐蔽的Bug。假设你把Group.nrofHosts设成10但外部文件里每个时间戳只有8个节点那ExternalMovement在读取到第9个节点ID时会发现数据行不足可能直接跳过或者复用上一行数据最终表现是某些节点始终在原点不动或者两个节点的轨迹完全一样。这种情况不会报错但会让仿真结果变得莫名其妙。所以在准备数据文件时我用脚本严格检查每个时间戳下出现的节点ID集合是否完整且一致确保永远是0到N-1这N个ID。我的习惯是生成文件后跑一段校验逻辑from collections import defaultdict def validate_external_file(path, expected_nodes): time_nodes defaultdict(set) for line in open(path): t, nid, x, y line.split() time_nodes[int(t)].add(int(nid)) for t, nodes in sorted(time_nodes.items()): if nodes ! expected_nodes: print(ftime {t} error: {nodes})不要嫌这一步麻烦它能在五分钟内帮你省下几个小时的无谓调试。4.4 坐标超出世界范围导致可视化异常节点飞出地图边框算是ExternalMovement最直观的“翻车现场”。我在第一次导入真实GPS轨迹时因为只做了投影没做缩放节点的坐标最大到了几万米GUI里节点全部跑到左上角的坐标系深处根本看不出来它们在动。检查坐标范围最直接的方法是在生成外部文件后扫描一下所有x和y值确认是否落在世界尺寸内。如果发现超出就用前面第3.2节里的归一化函数做一次缩放。同时要注意如果轨迹数据里有异常的离群点比如GPS漂移点简单的min-max缩放会把大部分正常轨迹压缩到很小的一片区域。更稳妥的做法是先做清洗剔除漂移点再做缩放。4.5 多个Group想用不同轨迹文件怎么办这个问题我印象很深是我在做一个多类节点混合仿真时遇到的我想让一部分车跑车载轨迹一部分人跑步行轨迹但ExternalMovement只认全局的MovementModel.externalFile两个Group读的是同一个文件根本没法分开。当时的变通方案是把两类轨迹合并到同一个外部文件用节点ID分段。车载节点用ID 0-49步行节点用ID 50-79一个时间戳下先输出车的位置再输出人的位置。然后用两个Group分别承载这两段ID对应的节点但两个Group都设置成ExternalMovement。这样虽然文件是一个但逻辑上两类节点的轨迹确实是分开的ID区间互不干扰。缺点是节点ID不能从0开始连续看起来不太优雅。如果这个变通方案满足不了需求那就只能改源码了。ExternalMovement的源码在movement/ExternalMovement.java里读取外部文件的关键逻辑就是构造函数和getLocation。你可以修改它让它从Group的设置项中读取当前Group单独的文件路径实现“每个Group一个轨迹文件”。这个方法需要你对ONE源码有基本的理解但收益是自由度大幅提升。4.6 大数据量文件导致内存占用过高ExternalMovement在构造时会一次性把整个数据文件读入内存如果轨迹数据非常庞大比如一个月的GPS数据采样间隔1秒文件可能几百MB加载过程会卡顿明显甚至直接内存溢出。解决办法有两个。一是降采样不必每1秒都输出改成5秒或10秒输出一次文件大小直接降一个数量级。前提是降采样后的轨迹不会影响接触事件的主要特征这需要你根据实验需求判断。二是对数据做分片每次只把仿真所需时间范围内的数据喂给ONE。比如仿真只跑30分钟那就把24小时的数据裁剪到30分钟文件自然小得多。5. 进阶把ExternalMovement用到更贴近真实场景5.1 多日数据与仿真时间映射真实轨迹往往跨越多天而仿真时间通常只有几十分钟到几小时。直接截取某一段数据能解决问题但如果你想利用多日数据的统计特征可以把多日相同时间段的数据拼接起来。比如你有5天早上8点到9点的轨迹可以把每天的数据当成一段独立的仿真场景分别跑5次仿真然后对结果做统计分析。这种做法在论文里叫“多场景重复实验”能显著提升实验的可信度。具体操作时我一般把每一天的数据单独生成一个外部文件然后用脚本批量修改配置里的MovementModel.externalFile和Scenario.name循环跑5次再汇总报表。ONE的配置是文本格式这个批量替换用Python的字符串处理就能轻松实现。5.2 与地图数据联合使用ONE里还有地图模式可以加载WKT格式的道路数据配合MapBasedMovement使用。ExternalMovement本身不关心地图节点位置完全由文件决定所以即便地图上有道路节点也可以跑到道路以外。这看起来是缺陷但反过来也是优势如果你的真实轨迹确实存在偏离道路的短距离路径比如车辆进入停车场ExternalMovement能如实地保留这些细节。不过要注意如果你的实验同时启用了地图相关报告比如统计节点在道路上的覆盖率那外部轨迹与地图的匹配度就很重要了。我在这种场景下会先把轨迹点与最近道路做匹配把明显偏离的漂移点投影到道路上再做坐标转换这样轨迹会显得更合理。5.3 对ExternalMovement做二次扩展的思路ExternalMovement在很多场景下够用但如果你追求更精细的控制可以直接在它的基础上扩展。比如想要节点在时间戳之间线性插值而不是瞬间跳变可以重写getLocation方法在相邻两个时间戳之间做线性插值。再比如想要限制节点的最大速度防止数据异常导致节点瞬移可以在每次更新后检查速度是否超限超限则使用上一次位置。我当时写过一个扩展版本给外部轨迹加了“信号覆盖开关”节点在某个时间段禁用通信接口用来模拟车辆进入隧道或者设备关机的场景。实现思路是在重写getNextPath或者getLocation时读取一段额外的控制时间表返回一个特殊状态给接口层。这种二次开发虽然会花些时间但能让你的仿真实验场景设计比别人多出不少花样。最后再分享一点个人习惯每次准备ExternalMovement数据文件之前我一定会先把轨迹点画成图看一眼确认没有明显跳变、漂移、静止异常之后才会生成ONE格式文件。这个习惯帮我避免了很多次无效实验。实际上仿真实验最耗时间的往往不是跑仿真本身而是你发现数据有问题的那个瞬间——前面好几个小时的仿真全白费了。先让数据可视化一遍比在ONE里调试省时得多。