简介:一份面向无线通信学习与仿真研究者的MATLAB小区调度算法仿真包,聚焦蜂窝网络中时隙、频率等无线资源的分配优化。包内单个.m脚本可实现多种典型调度策略(如最大信噪比、比例公平、最大速率等)的模拟对比,通过调整信道条件、QoS需求、用户移动性等参数,直观观察吞吐量、延迟与公平性指标变化,适合通信工程专业学生、算法初学者及正在做蜂窝网络课程设计的开发者参考。压缩包仅1个文件,大小仅1KB,为纯MATLAB源码,轻量易运行,便于直接阅读和修改核心逻辑。已有159人浏览学习。借助该脚本可快速搭建小区调度仿真框架,理解不同算法在真实场景中的优缺点,为后续扩展智能调度或认知无线电方案提供基础。
1. 蜂窝小区调度算法:为什么仿真结果总是跟理论差一截
蜂窝小区调度算法是基站侧决定"下一个时频资源块分给哪个用户"的规则,它直接影响吞吐量、时延和公平性。我在做蜂窝仿真时,发现最常翻车的不是信道建模,而是调度器实现——同一个算法,参数差一点,结果可能天差地别。这篇笔记会从调度算法的选型讲起,带你搭一个可复现的蜂窝仿真环境,把轮询、最大C/I、比例公平逐个落地,并给出参数调整和避坑经验。适合刚接触无线仿真、或者被仿真结果"玄学"困扰的工程师。
2. 蜂窝小区调度算法的基础:从轮询到比例公平的取舍
2.1 三种常用调度器:RR、Max C/I、PF的适用场景
调度算法决定了无线资源在多个用户之间怎么切分。做蜂窝仿真之前,先得把三种最基础的调度器搞明白:轮询(Round Robin,RR)、最大载干比(Max C/I)、比例公平(Proportional Fair,PF)。RR 的思路是"平均主义",每个用户按时间或资源块轮流拿资源,不关心当前信道质量。它的好处是绝对公平,实现简单,但坏处是信道好的用户不能多吃,导致系统吞吐量上不去。Max C/I 则反过来,每次都选瞬时 SINR 最高的用户,系统吞吐量能拉满,但边缘用户几乎拿不到资源,公平性指数很难看。PF 是两者之间的折中:它用"当前可达速率 / 历史平均速率"作为调度优先级,既照顾了信道好的用户,又给信道差的用户留了机会。
实际选型时,如果你的仿真目标是验证系统级 ASI 或频谱效率上限,Max C/I 做上界参考很合适;如果要模拟真实商用调度,PF 是 LTE 和 5G 的默认起点;如果只是做协议流程调试,RR 可以帮你忽略调度细节,先把物理层链路跑通。我一般会在同一个仿真框架里把三种都实现,用参数切换,这样对比结论更有说服力。下表总结了它们的特征:
| 调度器 | 公平性 | 系统吞吐量 | 实现复杂度 | 典型用途 |
|---|---|---|---|---|
| RR | 高 | 低 | 低 | 协议流程验证 / 基准对比 |
| Max C/I | 低 | 高 | 低 | 理论吞吐上界 / 极端场景 |
| PF | 中 | 中高 | 中 | 真实系统默认 / 性能折中 |
2.2 调度器的性能指标与仿真中的度量方式
跑仿真之前,先把指标口径定死,不然结果没法比。我常用的核心指标有三个:系统吞吐量(所有用户成功接收比特数之和除以仿真时长)、用户平均吞吐量、公平性指数(Jain Index)。公平性指数计算方法是 (sum(x_i))^2 / (N * sum(x_i^2)),其中 x_i 是每个用户的吞吐量,值越接近 1 越公平。另外,如果涉及实时业务,还要统计调度时延或丢包率,但基础蜂窝仿真里先把吞吐量和公平性跑明白。
在代码实现上,统计指标需要一个稳定累加器。下面这段 Python 代码我经常放在仿真主循环里,每个 TTI(传输时间间隔)结束调用一次,用来更新每个用户的实际吞吐量:
# 仿真时间单位:TTI(1ms),统计窗口可配置 class ThroughputTracker: def __init__(self, num_users, window_len_tti): self.num_users = num_users self.window_len = window_len_tti # PF 用的平均窗口,通常 100 个 TTI self.bits_per_user = [0] * num_users self.history = [] # 历史吞吐量,用于计算 PF 平均速率 def update(self, assigned_user_idx, bits): """每个 TTI 结束后,记录实际传输的比特数""" self.bits_per_user[assigned_user_idx] += bits def sliding_window_average(self): """返回每个用户在当前窗口内的平均速率(单位:bit/TTI)""" # 这里简化实现,实际工程会用 deque 维护滑动窗口 avg = [] for u in range(self.num_users): total = sum(self.bits_per_user[u] for _ in range(self.window_len)) # 示意 avg.append(total / self.window_len) return avg这段代码的逻辑是:每个用户维护累计比特数,窗口平均速率用于 PF 调度器的分母部分。注意代码里total的计算只是为了示意,真正实现时需要在每个 TTI 记录瞬时比特数到队列中,避免对整个窗口重复求和。参数window_len_tti是 PF 的滑动窗口长度,取值过小会让平均速率波动太大,调度决策抖动;取值过大会让平均速率反应迟钝,边缘用户迟迟得不到补偿。我常用的值是 100 TTI(即 100ms),对应 LTE 的 TTI 长度,实际网络中也常用这个量级。
指标统计还有一个容易踩坑的地方:系统吞吐量的分子要只统计「成功解码」的比特,而不是基站发出的比特。如果链路误码高,你发送了很多但用户没解出来,那这些比特不能算进吞吐量。仿真里通常在接收端做 CRC 校验,通过才累加,这个逻辑不能省。
3. 搭建蜂窝仿真环境:小区拓扑与用户移动模型
3.1 用 Python 搭一个简化的蜂窝小区模型:六边形小区与用户撒点
蜂窝仿真第一步是小区布局。我不想一上来就用全套的 19 小区模型,先从单个小区说起,但调度算法只有在有干扰的情况下才有意思,所以我通常至少做 3 小区或 7 小区同构布局。六边形小区是最贴近真实宏站覆盖的简化模型。生成六边形网格有个简单办法:给定小区半径 R,根据角度和中心点计算六个顶角坐标。
下面的代码可以生成单个六边形小区,并把用户均匀撒在小区覆盖范围内。实际工程中,用户分布常用泊松簇分布或均匀分布,我在这里用均匀分布跑通流程:
import math import random # 生成六边形小区顶点 def hexagon_vertices(center_x, center_y, radius): vertices = [] for i in range(6): angle = math.pi / 3 * i + math.pi / 6 # 从 30 度开始,保证平边朝上 vx = center_x + radius * math.cos(angle) vy = center_y + radius * math.sin(angle) vertices.append((vx, vy)) return vertices # 在小区内均匀撒点(拒绝采样) def drop_users_in_hexagon(center_x, center_y, radius, num_users, rng=random): users = [] while len(users) < num_users: # 生成一个正方形范围候选点 x = rng.uniform(center_x - radius, center_x + radius) y = rng.uniform(center_y - radius, center_y + radius) # 判断点是否落在六边形内:利用顶点向量与点的叉积符号 inside = True verts = hexagon_vertices(center_x, center_y, radius) for i in range(6): v1 = verts[i] v2 = verts[(i+1) % 6] if (v2[0] - v1[0]) * (y - v1[1]) - (v2[1] - v1[1]) * (x - v1[0]) > 0: inside = False break if inside: users.append((x, y, rng.uniform(0, 2*math.pi))) # (x, y, 方向角) return users这段代码先定义hexagon_vertices生成六边形的六个顶点,然后用拒绝采样在包含六边形的正方形里随机取点,再用叉积判断是否落在六边形内。注意角度起点设为 π/6,让六边形平边朝上,这样在多小区拼接时邻接关系更直观。drop_users_in_hexagon返回用户坐标和方向角,方向角用于后续移动模型。撒点密度根据小区半径和用户数决定,半径 500 米,用户 30 个时密度适中。
如果你需要多小区,只需要对每个小区中心点循环调用这个函数,并互相去重(边界上的点只归属一个小区)。我一般用捡球法:先给用户分配最近的小区中心,保证用户小区归属唯一性。这一步是后面干扰仿真的基础,归属错了,SINR 直接算错。
3.2 信道增益与干扰计算:不只是距离衰减
调度算法依赖用户反馈的 CQI,而 CQI 来源于 SINR。蜂窝仿真里 SINR 不能直接用路径损耗计算,还要叠加上阴影衰落和快衰落。一个常见的窄带 SINR 计算式是:SINR = P_tx * G_serving / (I_inter + N0 * B)。其中G_serving是服务小区的信道增益(包括路径损耗、阴影和快衰落),I_inter是来自其他同频小区的干扰功率之和,N0是噪声功率谱密度,B是资源块带宽。
下面给出一个单次快照的 SINR 计算函数,它假设每个用户已经归属到最近小区,并且基站发射功率相同,没有功率控制。这个代码适合做静止用户快照场景,也是调度算法最常用的测试环境:
import math def compute_sinr(user_pos, serving_bs_pos, all_bs_pos, tx_power_dbm, noise_dbm, bandwidth_hz, rng): """ user_pos: (x, y) serving_bs_pos: 所属基站坐标 all_bs_pos: 所有基站坐标列表(包括服务基站) tx_power_dbm: 基站发射功率 (dBm) noise_dbm: 噪声功率 (dBm) bandwidth_hz: 资源块带宽 (Hz) """ def path_loss_db(dist_km): # 简单宏站模型:128.1 + 37.5 * log10(d_km),单位 dB if dist_km < 0.035: dist_km = 0.035 # 最小距离限制 return 128.1 + 37.5 * math.log10(dist_km) def shadow_fading_db(): return rng.gauss(0, 8) # 对数正态阴影标准差 8dB # 服务小区链路增益 d_serv = math.hypot(user_pos[0] - serving_bs_pos[0], user_pos[1] - serving_bs_pos[1]) gain_serv = -path_loss_db(d_serv) - shadow_fading_db() # 其他小区干扰 interference_w = 0.0 for bs in all_bs_pos: if bs == serving_bs_pos: continue d_inter = math.hypot(user_pos[0] - bs[0], user_pos[1] - bs[1]) gain_inter = -path_loss_db(d_inter) - shadow_fading_db() # 将 dB 域的增益转为线性,乘发射功率(mW)得到干扰毫瓦 inter_mw = 10 ** ((tx_power_dbm + gain_inter) / 10) interference_w += inter_mw / 1000.0 # 噪声功率转为毫瓦再算 noise_mw = 10 ** (noise_dbm / 10) sinr_linear = (10 ** ((tx_power_dbm + gain_serv) / 10) / 1000.0) / (interference_w + noise_mw / 1000.0) sinr_db = 10 * math.log10(sinr_linear) return sinr_db这个函数里,shadow_fading_db()每次调用会生成独立的阴影衰落值,但真实场景中阴影衰落是空间相关的,两个位置相近的用户阴影值不该独立。如果你要严谨,应该生成一个相关阴影衰落地图,而不是每次独立采样。我这里是快照模型,让代码先跑通,后面再替换成地图采样。路径损耗模型用了 128.1 + 37.5 log10(d),对应 2GHz 频段的宏站参考值,如果你仿真的是 3.5GHz,需要换成 5G 市区模型(比如 36.873 里的 TR 36.873 UMa)。这些参数直接决定 SINR 绝对值,进而影响调度的触发阈值,务必根据频段和场景调整。
干扰计算循环里用bs == serving_bs_pos作比较,这里假设serving_bs_pos是all_bs_pos中的同一个元组对象。实际工程中一般给基站加 ID,用 ID 判断,避免对象比较的坑。SINR 算出来之后,需要映射到 CQI 或 MCS,调度器才能决定每个用户当前能传多少 bit。这个映射表通常来自链路级仿真,你可以先用一个简化香农公式:bits_per_symbol = min(6, max(0, log2(1 + 10**(sinr_db/10)))),再乘以资源块承载符号数,作为当前请求速率。调度算法正是基于这个速率的相对值做优先级排序。
4. 实现调度算法:核心代码与参数调优
4.1 比例公平(PF)调度器的实现与参数说明
比例公平是目前实战最常用的调度器,核心优先级公式是P = r_i / R_i,其中r_i是用户 i 在当前 TTI 的瞬时可达速率(根据 CQI 或 SINR 换算),R_i是用户 i 在过去一个窗口内的平均吞吐率。每个 TTI,基站对每个资源块计算所有候选用户的 P 值,选最大者分配。注意:PF 是半静态的,R_i必须在每个 TTI 更新,通常是R_i(t+1) = (1 - 1/Tc) * R_i(t) + (1/Tc) * rate_served_i,其中Tc是窗口长度。
我用 Python 实现一个最简单的单小区单资源块 PF 调度器,适合初学者理解逻辑:
class UserEquipment: def __init__(self, ue_id, sinr_db): self.id = ue_id self.sinr_db = sinr_db self.average_rate = 1e-6 # 初始给一个极小的平均速率,避免除零 def instantaneous_rate(self, bandwidth_hz): # 用香农公式简化换算极端 SINR 对应的速率 (bit/s) sinr_linear = 10 ** (self.sinr_db / 10) rate = bandwidth_hz * math.log2(1 + sinr_linear) return rate def pf_schedule(ues, bandwidth_hz, window_tti): """ ues: 候选用户列表 bandwidth_hz: 当前资源块带宽 window_tti: PF 窗口长度,用于更新平均速率 """ best_ue = None best_priority = -1 for ue in ues: if ue.average_rate <= 0: priority = float('inf') # 从未被调度的用户优先 else: r_inst = ue.instantaneous_rate(bandwidth_hz) priority = r_inst / ue.average_rate if priority > best_priority: best_priority = priority best_ue = ue # 更新所有用户的平均速率 for ue in ues: if ue == best_ue: # 被调度用户按实际传输速率更新 ue.average_rate = (1 - 1/window_tti) * ue.average_rate + \ (1/window_tti) * ue.instantaneous_rate(bandwidth_hz) else: ue.average_rate = (1 - 1/window_tti) * ue.average_rate return best_ue这个实现把 PF 优先级公式拆成两部分:瞬时速率除以历史平均速率。注意window_tti必须大于 1,否则指数平滑退化成直接覆盖。代码里对average_rate初始值给了1e-6,这是为了防止刚开始所有用户都没有历史吞吐量时除零。但更严谨的做法是初始时让所有用户发送一个小的假速率,或者第一轮用 RR 选,然后建立平均速率。实际工程中我经常用1e-6这种技巧,简单但有效。
参数bandwidth_hz如果模拟 LTE 的一个 PRB,就是 180kHz;如果模拟 5G NR 的一个 RB,子载波间隔 30kHz 时是 360kHz。带宽越大,瞬时速率越大,会影响 PF 优先级中分子的量级,但不会改变用户之间的相对排序,所以对调度结果影响不大。真正影响调度行为的是window_tti,它是这一节最值得调的参数。
4.2 调度周期、窗口长度与反馈时延的配置
调度周期在 LTE 里通常是一个 TTI(1ms),5G NR 里取决于子载波间隔,可以是 0.5ms 或 1ms。仿真里你需要把时间和资源块都离散化:每个 TTI 内,基站先接收用户发来的 CQI,然后运行调度器,再在同一个 TTI 的剩余符号里传输数据。这里有个容易忽略的反馈时延:实际系统中 CQI 是周期上报的,调度器拿到的可能是 4–8ms 前的信道状态。如果你仿真不加入这个延迟,得出的调度性能会偏乐观,尤其在高移动性场景。
下面这张表是我常用的参数默认值,你可以按自己的仿真场景调整:
| 参数名 | 推荐范围 | 我的常用值 | 调整影响 |
|---|---|---|---|
| TTI 时长 | 0.5–2 ms | 1 ms | 越短越接近实时但仿真更慢 |
PF 窗口长度window_tti | 50–200 | 100 | 越长越偏向吞吐量,越短越偏向公平性 |
| CQI 反馈周期 | 2–20 TTI | 5 TTI | 越大调度越滞后,系统吞吐量下降 |
| 用户数(每小区) | 10–100 | 30 | 越多用户 PF 多样增益越明显 |
| 资源块数 | 按带宽 | 25(5MHz) | 资源越多小区内干扰仿真越复杂 |
PF 窗口长度是最敏感的参数。window_tti设计为 20 时,平均速率更新很快,调度器会把资源补偿给前几个 TTI 没被调度的用户,公平性变好,但系统吞吐量会因为频繁切换用户而损失;window_tti设为 200 时,平均速率变化慢,调度器更倾向于持续服务信道好的用户,吞吐量上来了,但低 SINR 用户可能长时间拿不到资源。如果你发现仿真结果比标准论文里的参考曲线差很多,先检查这个参数是否被改成了极端值。
反馈时延的建模我也常用一个简单队列:每个 TTI 基站把当前 SINR 存入数组,延迟 N 个 TTI 后再给调度器使用。N 的取值范围参考 LTE 的实际 CQI 上报周期 5ms 和上报处理时延 2ms,我设 N=7。这个延迟会让高速移动用户的调度增益明显下降,是蜂窝仿真中体现"移动性代价"的关键点。不要省掉这一步,否则你的调度算法在用户速度 30km/h 时还能跑出接近静止的性能,那就太假了。
5. 蜂窝调度仿真常见问题与避坑:从数据翻车到结果复现
5.1 现象:调度结果不收敛,吞吐量抖动剧烈
仿真跑到 200 个 TTI 后,系统吞吐量没有趋于平稳,而是每分钟(以仿真 TTI 计)都在大起大落。原因通常是 PF 窗口长度过短,加上用户瞬时速率因为快衰落突变,导致调度优先级在每个 TTI 都换人。另一个原因是平均速率更新时没有把物理资源块大小考虑进去,导致统计口径不一致。我的解决方法是先把window_tti逐步加大,调到 100 以上观察抖动幅度;如果还抖,检查瞬时速率映射函数是否对负 SINR 做了正确处理——SINR 是负的 dB 值时,线性换算比 1 还小,香农公式算出的速率可能为 0,这时优先级分子为 0,这一轮调度器可能空转。我给负 SINR 设置一个最小速率下限(比如 10 bit/TTI),保证每个用户有机会被选到,但很小概率。经过这两处调整,吞吐量曲线通常会平滑下来。
5.2 现象:换了随机种子,结论就变了
有时候我在同一个代码里只换了随机种子,PF 调度的用户平均吞吐量排序就颠倒了。原因在于用户撒点、阴影衰落、快衰落全都依赖随机数,如果仿真时间不够长,统计窗口没达到各态历经的要求,结果就是"一次仿真一个样"。解决方法是固定多种随机种子跑多次,取平均。我通常用 10 个随机种子,每个种子仿真 2000 个 TTI,最后对比均值与方差。还有个小技巧:把用户撒点单独固定,只让信道衰落随机变化,这样能分离信道随机性与用户位置带来的影响。另外,Jain 公平性指数对随机种子也敏感,我在论文里会列出 5 个种子的最大值和最小值,作为误差棒,而不是只给一条平均线。
5.3 现象:边缘用户始终被饿死,公平性指标异常
PF 本应兼顾边缘用户,但仿真里边缘用户吞吐量常年为 0。原因有两类:一是路径损耗模型里最小距离限制设得太小,边缘用户 SINR 低到一定程度后,瞬时速率被映射成了 0,调度优先级恒为 0;二是 PF 平均速率初始值设置成 1e-6 后,如果边缘用户长时间不被选,average_rate会因为指数衰减趋近于 0,于是下一次它的优先级变得极大,反而应该被选——但实际代码里如果r_inst也被 0 了,分子分母都是 0 就出 NaN。解决方法是给瞬时速率加一个绝对下限,或者在调度器里加一个"最大等待 TTI"逻辑:如果某个用户超过 N 个 TTI 没被调度,下一轮强制分配一个资源块给它。这个 N 我通常设 50 TTI。强制调度会稍微损失系统吞吐量,但能保住公平性曲线,也符合实际系统里保障最小传输速率的要求。
5.4 其他高频坑:SINR 算错与 CQI 映射失配
再说一个我常遇到的坑:SINR 计算时干扰功率忘了乘资源块带宽。干扰应该占用与信号相同的带宽,如果信号是 180kHz 带宽,干扰功率也需要在 180kHz 内积分,而不是把整个系统带宽的干扰噪声全部算进来。这会导致 SINR 被低估 10dB 以上,调度器几乎只选就近用户。解决方法是确认所有功率和噪声都以同一带宽归一化,在代码里用bandwidth_hz显式传入并统一到同一分母。另外,从 SINR 映射 CQI 时,很多仿真直接用香农公式,但实际 4G/5G 的 MCS 调制编码有离散台阶,不是连续速率。建议用 3GPP 的 MCS 表,把 SINR 分段映射,至少分成 0~15 共 16 档 CQI,这样调度优先级才有物理意义。我最初就是直接拿香农公式算速率,结果 Max C/I 调度器得到的吞吐量比协议仿真上限还高,明显违背理论界。
6. 验证调度算法的一种实用方法:把仿真结果跟理论界对比
调度算法写完,怎么知道它对不对?我常用一个零干扰、单用户、静态信道下的"香农极限"验证。把基站和用户固定在理想位置,关闭所有干扰和衰落,只有一个用户占用全部带宽,此时 PF、Max C/I、RR 都应该收敛到同一个最大可达速率。如果 SIR 仿真值高于香农极限,说明路径损耗或 SINR 计算有 bug。
具体做法是:取小区半径 500m,用户距离基站 100m,带宽 5MHz,发射功率 43dBm,噪声功率谱密度 -174dBm/Hz。按 3GPP 路径损耗模型算出 SINR,再用带宽 * log2(1+SINR)算出理论速率。如果仿真结果与理论值偏差超过 1%,就检查单位换算——dBm 与 mW 的转换、分贝转线性是否写反。下面是我用的一行验证算式,可以直接抄过去对比:
bandwidth = 5e6 # 5 MHz tx_power_dbm = 43 distance_km = 0.1 pl_db = 128.1 + 37.5 * math.log10(distance_km) rx_power_dbm = tx_power_dbm - pl_db noise_db = -174 + 10 * math.log10(bandwidth) # 热噪声 in dBm sinr_db = rx_power_dbm - noise_db theoretical_rate_mbps = (bandwidth * math.log2(1 + 10**(sinr_db/10))) / 1e6 print(f"Theory rate: {theoretical_rate_mbps:.2f} Mbps")这个验证给我抓过不少次单位翻车。有一次我把噪声功率谱密度 -174dBm/Hz 直接当成总噪声功率,结果 SINR 高了 70dB,调度器每个用户都选了 Max C/I,系统吞吐量跟理论界差了 50 倍。从那以后,我养成了习惯:每次改信道模型参数,先跑这个单用户理想场景,确认速率合理,再开多用户调度。希望这个习惯也能帮到你——仿真结果不是跑出来就完了,至少和理论界对一次,心里才有底。
本文还有配套的精品资源,点击获取