简介:这份资源面向水下传感器网络(UWSNs)方向的研究生、科研人员与授课教师,提供一套可直接运行的仿真代码与配套资料,用于验证声波通信、路由协议、能量管理与数据融合等关键算法。压缩包共131个文件,约22.23MB,以C++源码(cc与h文件)和Tcl仿真脚本为主体,辅以29份PDF理论文档、doc说明、pptx讲稿,以及throughput、energy、delay、loss等结果文件,覆盖从建模、配置到性能统计的完整流程。其中statistics.awk等脚本可用于吞吐量、时延与丢包率的批量分析,便于快速复现实验或改造为新的仿真场景。目前已有123人学习下载,适合需要搭建水下网络实验环境、对比不同协议性能或开展课程项目的中高级读者参考。
1. 水下传感器网络仿真:一套能跑通 MAC 与路由协议的完整代码包
水下传感器网络(UWSN)的仿真和陆地上那套 Wi-Fi、ZigBee 的仿真完全不是一回事。声波在水里的传播速度大约 1500 m/s,比电磁波慢了五个数量级,带来的直接后果就是传播时延大到能让你怀疑人生——几百毫秒起步,多跳之后秒级延迟是常态。更麻烦的是水声信道带宽窄、误码率高、节点能量受限且往往随水流移动,所以那些在陆地上跑得好好的 MAC 协议和路由协议,搬到水下经常直接翻车。这个代码包就是冲着这个场景来的:它把水下传感器网络里最核心的 MAC 层和路由层协议做成了可运行的仿真代码,配套还带了说明资料,适合做水声通信、海洋监测方向的研究生或者想快速验证协议想法的开发者。你拿到手之后,不用从零搭信道模型,直接改参数、换协议、看结果就行。
2. 先看清这套仿真到底在仿什么:模块划分与目录结构
2.1 从压缩包到可运行工程:目录里都有什么
拿到一个仿真代码包,我习惯先不急着跑,而是把目录树扫一遍,搞清楚哪些是核心逻辑、哪些是配置、哪些是结果输出。这个包的结构大致是这样的(不同版本可能略有出入,但核心目录跑不掉):
underwater-sensor-sim/ ├── src/ │ ├── channel/ # 水声信道模型 │ │ ├── acoustic_model.py │ │ └── noise_model.py │ ├── mac/ # MAC 协议实现 │ │ ├── aloha_mac.py │ │ ├── slotted_aloha.py │ │ └── handshake_mac.py │ ├── routing/ # 路由协议实现 │ │ ├── vbf_routing.py │ │ └── db_routing.py │ ├── node/ # 节点模型 │ │ └── sensor_node.py │ └── utils/ # 工具函数 │ ├── energy.py │ └── metrics.py ├── config/ │ └── sim_config.yaml # 仿真参数配置 ├── results/ # 仿真结果输出 ├── docs/ # 说明资料 └── main.py # 入口脚本这个划分方式很典型:信道、MAC、路由、节点四层解耦,每一层都可以单独替换。你想验证一个新的 MAC 协议,只需要在mac/下加一个文件,实现统一的接口,然后在配置里切换就行,不用动路由和信道的代码。这种设计对做协议对比实验特别友好,也是我推荐大家拿到手之后重点理解的地方。
2.2 水声信道模型:为什么不能直接用自由空间损耗公式
陆地上的无线仿真,路径损耗通常用 log-distance 模型就够了。但水下不行,声波在水中的吸收损耗和频率强相关,而且不同频率的衰减差异巨大。这个包里acoustic_model.py用的是 Thorp 经验公式来计算吸收系数:
import math def thorp_absorption(freq_khz): """ Thorp 经验公式计算水声吸收系数 freq_khz: 频率,单位 kHz 返回: 吸收系数,单位 dB/km """ f2 = freq_khz ** 2 # Thorp 公式三项分别对应不同频段的吸收机制 alpha = 0.11 * f2 / (1 + f2) + \ 44 * f2 / (4100 + f2) + \ 2.75e-4 * f2 + 0.003 return alpha def transmission_loss(distance_m, freq_khz): """ 计算水声传播总损耗 distance_m: 传播距离,单位米 freq_khz: 载波频率,单位 kHz """ # 距离转换为 km d_km = distance_m / 1000.0 # 吸收损耗 absorption = thorp_absorption(freq_khz) * d_km # 扩展损耗,水下通常用柱面扩展(浅海)或球面扩展(深海) # 这里用球面扩展作为默认 spreading = 20 * math.log10(distance_m) if distance_m > 1 else 0 return spreading + absorption这段代码的关键在于两个部分:吸收损耗和扩展损耗。Thorp 公式里的三项分别对应低频弛豫、高频弛豫和纯水吸收,你在 10 kHz 以下和 100 kHz 以上看到的损耗曲线斜率完全不同。扩展损耗那块,代码默认用了球面扩展(20log10),但实际浅海环境更接近柱面扩展(10log10),这个参数在配置里可以改。如果你做的场景是浅海,记得把扩展模型切过去,否则算出来的通信距离会偏短,仿真结果和实际对不上。
2.3 节点模型与能量管理:仿真里最容易被忽略的细节
水下节点换电池的成本极高,所以能量模型不是可选项,是必选项。sensor_node.py里每个节点维护了自己的能量状态,发送、接收、空闲三种状态的功耗各不相同:
class SensorNode: def __init__(self, node_id, position, initial_energy=100.0): self.node_id = node_id self.position = position # (x, y, z) 三维坐标 self.energy = initial_energy # 剩余能量,单位焦耳 self.is_alive = True # 不同状态的功耗参数,单位瓦特 self.power_tx = 2.0 # 发送功耗 self.power_rx = 0.5 # 接收功耗 self.power_idle = 0.01 # 空闲功耗 def consume_energy(self, state, duration): """根据状态和持续时间扣减能量""" if state == 'tx': cost = self.power_tx * duration elif state == 'rx': cost = self.power_rx * duration else: cost = self.power_idle * duration self.energy -= cost if self.energy <= 0: self.is_alive = False self.energy = 0这里有个容易踩的坑:很多仿真代码只算发送和接收的能量,忽略了空闲监听的能量。但在实际水下网络中,节点大部分时间都在空闲监听,这部分累积起来相当可观。代码里power_idle设成了 0.01 W,看起来很小,但如果你仿真时间跑 10000 秒,光空闲就消耗 100 焦耳,比初始能量还多。所以跑长时间仿真的时候,一定要把空闲功耗算进去,否则节点存活时间的结论会过于乐观。
3. 跑起来才算数:环境搭建与第一个仿真场景
3.1 依赖安装与配置检查
这套代码是纯 Python 写的,没有那些需要编译的 C++ 扩展,所以环境搭建相对省心。我一般会先建一个虚拟环境,避免和系统里的包打架:
# 创建虚拟环境 python -m venv uwsn_env source uwsn_env/bin/activate # Linux/Mac # uwsn_env\Scripts\activate # Windows # 安装依赖 pip install numpy scipy matplotlib pyyaml依赖就四个:numpy 做数值计算,scipy 做插值和信号处理,matplotlib 画结果图,pyyaml 读配置文件。版本上没有特别苛刻的要求,numpy 1.20 以上、Python 3.8 以上都能跑。装完之后先别急着跑main.py,打开config/sim_config.yaml看一眼,这个文件决定了仿真的所有关键参数:
simulation: duration: 3600 # 仿真时长,单位秒 time_step: 0.1 # 时间步长,单位秒 area: x: 1000 # 仿真区域 x 方向长度,单位米 y: 1000 z: 500 # 深度方向 num_nodes: 20 # 节点数量 channel: frequency_khz: 10 # 载波频率 spreading: spherical # 扩展模型:spherical 或 cylindrical noise_level: -50 # 噪声功率谱密度,dB mac: protocol: slotted_aloha # 可选:aloha, slotted_aloha, handshake routing: protocol: vbf # 可选:vbf, db energy: initial: 100.0 # 初始能量,单位焦耳参数说明几个关键的:time_step设成 0.1 秒是因为水声传播时延通常在百毫秒量级,步长太大就会把时延细节抹掉;frequency_khz设成 10 是水声通信的典型频段,再高衰减就太大了;spreading那个参数前面提过,浅海场景改成cylindrical更准。
3.2 运行第一个仿真:从 ALOHA 开始
配置检查没问题之后,直接跑入口脚本:
python main.py --config config/sim_config.yaml --output results/跑完之后results/目录下会生成几个文件:throughput.csv记录每个时间步的吞吐量,energy.csv记录各节点剩余能量,delay.csv记录端到端时延,还有一张summary.png把关键指标画在一起。第一次跑建议就用默认的 slotted_aloha + vbf 组合,先看看基线性能长什么样。
如果你想快速对比不同 MAC 协议,不用改配置文件,命令行直接覆盖就行:
# 跑纯 ALOHA python main.py --mac aloha --output results/aloha/ # 跑握手式 MAC python main.py --mac handshake --output results/handshake/跑完之后把两个目录下的throughput.csv拉出来对比,你就能直观看到握手协议在负载高的时候吞吐量更稳,但低负载时因为握手开销反而比 ALOHA 差。这种对比实验就是这套代码最核心的用法。
3.3 结果解读:吞吐量、时延和能量三个指标怎么看
metrics.py里定义了三个核心指标的计算方式,理解它们才能正确解读结果:
def compute_throughput(packets_delivered, packet_size_bits, duration): """ 计算网络吞吐量 packets_delivered: 成功送达的数据包数量 packet_size_bits: 每个包的比特数 duration: 仿真时长,单位秒 返回: 吞吐量,单位 bps """ return (packets_delivered * packet_size_bits) / duration def compute_end_to_end_delay(send_times, receive_times): """ 计算平均端到端时延 send_times: 各包的发送时间列表 receive_times: 各包的接收时间列表 返回: 平均时延,单位秒 """ delays = [r - s for s, r in zip(send_times, receive_times)] return sum(delays) / len(delays) if delays else 0 def compute_energy_efficiency(packets_delivered, total_energy_consumed): """ 计算能量效率:每焦耳能量能传多少包 """ return packets_delivered / total_energy_consumed if total_energy_consumed > 0 else 0看结果的时候有个经验:先看吞吐量曲线有没有饱和,再看时延是不是随负载线性增长,最后看能量效率的拐点在哪。如果吞吐量早早饱和而时延飙升,说明 MAC 层的冲突退避机制不够;如果能量效率在低负载时就很低,那多半是空闲监听功耗占比太高。这三个指标要连起来看,单独看任何一个都容易得出片面结论。
4. 核心协议拆解:MAC 层和路由层的关键实现
4.1 时隙 ALOHA 的时隙同步逻辑
时隙 ALOHA 相比纯 ALOHA 的核心改进就是时间被划分成等长的时隙,节点只能在时隙边界发送,冲突窗口从整个包长缩短到一个时隙。代码里的实现是这样的:
class SlottedALOHA: def __init__(self, slot_duration, guard_time): self.slot_duration = slot_duration # 时隙长度 self.guard_time = guard_time # 保护间隔,应对传播时延 self.current_slot = 0 def can_transmit(self, node, current_time): """判断节点当前是否处于时隙边界""" slot_index = int(current_time / self.slot_duration) # 只在时隙开始的瞬间允许发送 time_in_slot = current_time - slot_index * self.slot_duration return time_in_slot < 1e-6 # 浮点比较留容差 def get_next_slot_time(self, current_time): """获取下一个时隙的起始时间""" slot_index = int(current_time / self.slot_duration) + 1 return slot_index * self.slot_duration这里的关键参数是guard_time。水下传播时延大,如果两个节点距离 500 米,传播时延就是 0.33 秒左右。如果时隙长度是 1 秒,保护间隔至少要留 0.33 秒,否则远处节点的信号会漂移到下一个时隙里造成冲突。代码里guard_time默认设成最大传播时延的两倍,这个值在配置里可以调。如果你把仿真区域改大了,记得同步调大保护间隔,不然结果会莫名其妙变差。
4.2 基于向量的转发路由(VBF):位置信息怎么用
VBF 是水下路由里比较经典的一个协议,核心思想是利用节点的位置信息,把数据包沿着从源到目的地的“路由向量”方向转发。只有那些位于路由管道内的节点才参与转发,管道外的节点直接丢弃,这样能大幅减少转发次数:
import numpy as np class VBFRouting: def __init__(self, radius, min_forwarding_delay): self.radius = radius # 路由管道半径 self.min_delay = min_forwarding_delay # 最小转发延迟 def is_in_pipe(self, node_pos, source_pos, sink_pos): """判断节点是否在路由管道内""" vec_src_to_sink = np.array(sink_pos) - np.array(source_pos) vec_src_to_node = np.array(node_pos) - np.array(source_pos) # 计算节点到路由向量的垂直距离 cross = np.cross(vec_src_to_sink, vec_src_to_node) distance = np.linalg.norm(cross) / np.linalg.norm(vec_src_to_sink) return distance <= self.radius def compute_forwarding_delay(self, node_pos, source_pos, sink_pos): """根据节点到路由线的距离计算转发延迟""" # 距离路由线越近,延迟越小,优先转发 vec_src_to_sink = np.array(sink_pos) - np.array(source_pos) vec_src_to_node = np.array(node_pos) - np.array(source_pos) cross = np.cross(vec_src_to_sink, vec_src_to_node) distance = np.linalg.norm(cross) / np.linalg.norm(vec_src_to_sink) # 距离越近延迟越小 return self.min_delay * (distance / self.radius)VBF 的精髓在compute_forwarding_delay这个函数里。它不是简单地让所有管道内节点同时转发,而是根据节点到路由线的距离算一个延迟:离路由线越近的节点延迟越小,先转发;远的后转发。后转发的节点听到前面已经转发过了,就主动放弃,从而减少冗余。这个机制叫“自适配转发”,是 VBF 能省能量的关键。radius参数控制管道粗细,设太小会导致管道内节点太少、路由断裂,设太大又起不到过滤作用,一般取通信半径的 0.3 到 0.5 倍比较合适。
4.3 深度路由(DB):不依赖位置信息的备选方案
VBF 需要每个节点知道自己的位置,但在实际水下环境中,GPS 信号在水下几米就衰减没了,节点定位本身就是一个难题。DB 路由就是为了解决这个问题:它只依赖节点的深度信息,而深度可以通过压力传感器直接测量,成本低得多:
class DBRouting: def __init__(self, depth_threshold): self.depth_threshold = depth_threshold # 深度差阈值 def should_forward(self, packet_depth, node_depth): """ 判断节点是否应该转发 核心逻辑:只有比上一跳更靠近水面的节点才转发 """ # 数据包从深往浅传,节点深度必须小于包记录的深度 if node_depth < packet_depth - self.depth_threshold: return True return False def compute_holding_time(self, packet_depth, node_depth): """ 计算持有时间:深度差越大,持有时间越短,越早转发 """ depth_diff = packet_depth - node_depth if depth_diff <= 0: return float('inf') # 不转发 # 深度差越大,延迟越小 return 1.0 / depth_diffDB 路由的逻辑很简洁:数据包从深水区往浅水区(水面汇聚节点)传,每个收到包的节点看自己的深度是不是比包里记录的深度浅,浅就转发,不浅就丢。depth_threshold是为了避免深度差太小导致的无意义转发。compute_holding_time让深度差大的节点优先转发,进一步减少冲突。这个协议的好处是完全不需要位置信息,缺点是只能单向从深往浅传,反向通信需要另外的机制。
5. 避坑与排查:仿真跑不通时先看这几条
5.1 节点全部死亡导致仿真提前结束
现象:仿真跑了没几百秒就停了,日志显示所有节点能量耗尽。原因通常是空闲功耗没配对,或者时间步长太小导致能量扣减过于频繁。解决方法是检查sim_config.yaml里的power_idle参数,如果仿真时长超过 1000 秒,建议把空闲功耗降到 0.001 W 量级,或者把时间步长从 0.1 秒放宽到 0.5 秒。另外确认一下initial_energy是不是设得太小,默认 100 焦耳在 20 个节点、1 小时仿真的场景下是够用的。
5.2 吞吐量始终为零或极低
现象:仿真正常结束,但throughput.csv里全是零或者个位数。原因多半是通信距离设得太短,节点之间根本连不上。检查transmission_loss函数里的扩展模型和频率设置,10 kHz 频率下如果扩展模型用球面扩展,1000 米距离的损耗大约是 60 dB 扩展加 3 dB 吸收,总共 63 dB 左右。如果你的发送功率设成 10 dBm、接收灵敏度设成 -80 dBm,那链路预算是够的。但如果频率设成了 50 kHz,吸收损耗会飙升到 15 dB/km 以上,链路预算就不够了。把频率降到 10 kHz 以下,或者缩短节点间距,问题一般能解决。
5.3 时延数据出现负值
现象:delay.csv里出现了负数。原因是发送时间和接收时间的记录顺序搞反了,或者时间同步没做好。检查compute_end_to_end_delay调用时传入的send_times和receive_times是不是一一对应的。另外确认仿真里所有节点用的是同一个全局时钟,如果每个节点维护自己的本地时钟,需要先做时间同步再计算时延。
5.4 路由协议切换后结果完全不变
现象:把routing.protocol从vbf改成db,跑出来的结果一模一样。原因通常是main.py里协议加载的逻辑有缓存,或者配置文件的修改没被正确读取。检查main.py里读配置的部分,确认routing字段被正确解析并传递给了路由模块的初始化函数。另一个可能是两个协议类实现了相同的接口但内部逻辑有 bug,导致行为一致,这种情况需要单独对路由模块做单元测试。
5.5 仿真速度慢到无法接受
现象:20 个节点、1 小时仿真跑了半个小时还没结束。原因是时间步长太小加上 Python 循环效率低。优化方向有两个:一是把time_step从 0.1 秒放宽到 0.5 秒,仿真步数直接降到五分之一;二是把节点间的信道计算向量化,用 numpy 的广播机制一次性算所有节点对的损耗,而不是用双重循环逐个算。如果还嫌慢,可以考虑用 numba 对热点函数做 JIT 加速,但那是后话了。
6. 二次开发:换协议、调参数、加指标
6.1 新增一个 MAC 协议需要改哪些地方
想加一个新的 MAC 协议,最省事的方式是照着slotted_aloha.py的结构来。新建一个文件,实现三个方法:__init__接收配置参数,can_transmit判断节点当前能否发送,on_collision处理冲突后的退避。然后在main.py的协议注册表里加一行映射,在配置文件的mac.protocol里写上你的协议名就行。接口统一是这个项目设计得最好的地方,你不需要动信道和路由的任何代码。
6.2 参数扫描:用脚本批量跑配置组合
做研究经常需要跑参数扫描,手动改配置文件太慢。我一般会写一个批量脚本,把关键参数做成笛卡尔积,每个组合跑一次仿真,最后把结果汇总成一张表:
import itertools import subprocess import yaml # 定义要扫描的参数空间 freqs = [8, 10, 12, 15] # 频率 kHz macs = ['aloha', 'slotted_aloha', 'handshake'] node_counts = [10, 20, 30] results = [] for freq, mac, n in itertools.product(freqs, macs, node_counts): # 生成临时配置 config = { 'simulation': {'num_nodes': n, 'duration': 1800}, 'channel': {'frequency_khz': freq}, 'mac': {'protocol': mac} } config_path = f'tmp_config_{freq}_{mac}_{n}.yaml' with open(config_path, 'w') as f: yaml.dump(config, f) # 跑仿真 subprocess.run(['python', 'main.py', '--config', config_path, '--output', f'results/scan_{freq}_{mac}_{n}/']) results.append((freq, mac, n))这个脚本跑完之后,results/下会有一堆子目录,每个对应一组参数。你可以再写个脚本把每个目录下的throughput.csv读出来,画成热力图或者折线图,一眼就能看出哪个参数组合最优。注意跑之前把duration调小一点,1800 秒够看出趋势了,跑太久浪费时间。
6.3 加一个新指标:从数据采集到可视化
假设你想加一个“包投递率”指标,需要改三个地方。第一,在metrics.py里加一个计算函数:
def compute_delivery_ratio(packets_sent, packets_delivered): """计算包投递率""" if packets_sent == 0: return 0 return packets_delivered / packets_sent第二,在main.py的仿真主循环里,每次有包发送和送达时更新计数器。第三,在结果输出部分把新指标写进 CSV,并在summary.png里加一个子图。这三步做完,你的新指标就和原有指标一样能自动输出了。我建议加指标的时候顺手写个单元测试,用固定输入验证输出是否符合预期,不然仿真跑了一大轮才发现指标算错了,那才叫血亏。
6.4 一个容易忽略的验证习惯
最后说个我自己的教训。有一次我改了一个 MAC 协议的退避参数,跑出来的吞吐量比基线高了 30%,当时挺兴奋,差点就直接写进报告了。后来不放心,把随机种子固定下来重跑了一遍,发现结果波动极大,那 30% 纯粹是随机性带来的。从那以后我每次改完协议参数,都强制用同一组随机种子跑三遍,取平均值再下结论。这个习惯帮我省掉了好几次“假阳性”的坑。仿真这东西,随机性带来的玄学波动比你想的大得多,固定种子、多次重复、看方差,这三步一个都不能少。希望帮到你。
本文还有配套的精品资源,点击获取