说实话,做机会网络(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_width=4500.0, world_height=3400.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, key=lambda 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(f"time {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里调试省时得多。