简介:这是一份基于Java的摩天大楼电梯系统仿真项目,核心聚焦电梯类型划分与调度算法优化,适合学习面向对象编程、数据结构与算法设计的中高级Java开发者。项目覆盖单向、双向、观光、高速、消防等常见电梯类型,并实现先来先服务、最近楼层优先、最少行程时间、预调度及群控等调度策略,可直观比较不同算法对乘客等待时间与系统吞吐量的影响。压缩包共75个文件,以25个Java源码为主体,辅以XML配置、prefs偏好设置及少量文档,整体仅70KB,目录结构清晰,便于按模块阅读。目前已有402人学习。借助Elevator、Call、Scheduler、TrafficGenerator等核心类,读者可理解随机客流生成、电梯响应与多梯协同控制的完整链路,既能巩固Java集合、多线程等技能,也能为大型建筑智慧调度设计提供参考。
1. 电梯仿真到底在仿什么:电梯类型只是骨架,调度算法才是命门
第一次跑摩天大楼电梯仿真的人,多半会栽在同一个地方:以为难点在「电梯怎么走」,忙了半天加速度、限速器、平层误差,结果真正影响大楼体验的,是乘客在厅站排队等着进电梯的那几十秒。电梯仿真看着像运动学模拟,骨子里是排队系统仿真——乘客按随机过程到达,轿厢作为有限服务台来回搬运,电梯类型决定搬运的物理上限,调度算法决定这个上限能用出几成。说得直白点,调度算法才是摩天大楼垂直交通的命门。这个仿真项目,就是把电梯类型、调度算法、客流模型三件事拆开,用一台普通电脑把整栋楼一天的电梯运行跑完,用数据回答「该用几台电梯、什么结构、跑什么策略」。适合正在做调度算法验证、智能建筑方案比选,或者需要一组可信对比数据撑住结论的从业者。新手能顺着一条主线跑通,熟手则能在参数和边界里抠到自己要的东西。
2. 把电梯类型拆开建模:单厢、双厢、双层轿厢在仿真里的差别
2.1 单厢、双厢、双层轿厢:结构差异对应哪些仿真参数
常见的酒店、写字楼用的就是单厢电梯:一个井道一个轿厢,上下独立运行。仿真里最省事,一台电梯维护一个楼层位置、一个方向、一组停靠请求就够了。双厢电梯(Twin)则是在同一个井道里放两个独立轿厢,各走各的,靠控制系统保证上下不撞车,吞吐量比单厢高出一截;但它不是免费的午餐,两个轿厢在同一个竖直通道里运动,仿真必须加一条「同井道间距约束」,否则会出现两个轿厢在井道里穿过彼此的鬼畜画面。
双层轿厢(Double-Deck)是另一种思路:一个轿厢框架带上下两个客舱,停靠时同时对齐相邻两个楼层,适合那种每层人很多、停留时间短的大进深建筑。仿真里的差异更明显——它本质上是一个「有两个开门面的整体」,分配请求时得同时分配上下两层,模型里不再是两个独立对象,而是一个复合对象带两套停靠逻辑。
三种结构落到仿真参数上,真正要调的只有五个:轿厢数量、额定载客量、最大速度、加速度、每层开关门时间。双厢额外加一项「井道内最小间距」,双层额外加一项「两层站台是否对齐」。其余像抱闸、曳引比、限速器这些物理参数,对调度算法验证几乎没有影响,仿真里塞进去只会拖慢迭代速度。
2.2 分区策略与空中大堂:摩天大楼井道布局怎么影响调度算法
超过三十层的楼,电梯井道会占掉大量核心筒面积,所以摩天大楼几乎都会做分区:低区电梯只跑 1 到 30 层,中区只跑 31 到 60 层,高区只跑 61 到 90 层。更极端的超高层还会做空中大堂(Sky Lobby),乘客先坐穿梭电梯到空中大堂,再换区间电梯去更高楼层。这个设计在建筑上是为了省井道,在调度上等于硬生生加了一层「换乘约束」。
仿真里处理分区,最常见做法是给每台电梯挂一个「可停靠楼层集合」。低区电梯的停靠集合是 1 到 30,高区电梯的停靠集合是 1、61 到 90,那 1 楼就是换乘节点。调度算法分配乘客时,先判断乘客目标楼层落在哪个区间,再从「能到那个区间的电梯」里挑,而不是全局随便挑。这个约束看起来简单,却是新手最容易漏的地方——不做分区约束的模型,算法会疯狂把高区乘客塞给低区电梯,等待时间曲线直接失真。
空中大堂的仿真还要额外设置「换乘惩罚时间」,我一般习惯给 60 到 90 秒。原因很朴素:换乘意味着出电梯、走一段、重新排队、再进另一台电梯,这中间的时间成本是真实存在的。如果仿真里不设这个惩罚,模型会预测「去空中大堂换乘」和「直达」一样快,那结论就完全偏了。
2.3 用状态机表达电梯:仿真里避免「电梯到了还在动」的经典错误
写电梯仿真最容易出的逻辑错误,就是电梯明明已经到站,乘客还没上完,电梯已经开始计算下一段行程。用状态机把所有电梯行为约束成「某一时刻只能处于一个状态」,这种错误基本就绝迹了。我通常这样定义基础状态:空闲、加速、匀速、减速、停靠、开门、关门、上下客。
from enum import Enum class ElevatorState(Enum): IDLE = 0 # 停靠且无方向,等待新请求 ACCELERATING = 1 # 正在加速 RUNNING = 2 # 匀速运行 DECELERATING = 3 # 正在减速,准备平层 DOCKED = 4 # 已到楼层,门未开 DOOR_OPEN = 5 # 开门状态 LOADING = 6 # 乘客进出轿厢 DOOR_CLOSE = 7 # 关门状态 # 合法状态迁移表:仿真主循环只允许按表内方向迁移 ALLOWED_TRANSITIONS = { ElevatorState.IDLE: {ElevatorState.ACCELERATING}, ElevatorState.ACCELERATING: {ElevatorState.RUNNING, ElevatorState.DECELERATING}, ElevatorState.RUNNING: {ElevatorState.DECELERATING}, ElevatorState.DECELERATING: {ElevatorState.DOCKED}, ElevatorState.DOCKED: {ElevatorState.DOOR_OPEN}, ElevatorState.DOOR_OPEN: {ElevatorState.LOADING, ElevatorState.DOOR_CLOSE}, ElevatorState.LOADING: {ElevatorState.DOOR_CLOSE}, ElevatorState.DOOR_CLOSE: {ElevatorState.IDLE, ElevatorState.ACCELERATING}, }这段定义里最关键的是DOOR_CLOSE可以回IDLE也可以直接回ACCELERATING:关门后如果还有同方向的后续请求,电梯直接启动;如果请求清空,回到空闲。双厢电梯的差异也要在状态机上体现——每个轿厢各维护一套状态机,但同时增加一条全局约束:同一井道内两个轿厢的物理位置不能越过安全间距。双层轿厢则反过来,它只有一套状态机,但LOADING时要同时从上下两层上下客,所以乘客分配逻辑要成对处理。
状态机的价值在于把「电梯当前在干什么」变成一个可控集合。每次主循环推进时,只需要查询当前状态、判断下一个合法状态,然后执行对应的运动学计算,逻辑清晰,也不容易出现「电梯已经到站还在楼层之间飘」的灵异现象。
3. 从 FCFS 到目的层选梯:电梯调度算法的选型与参数
3.1 基础算法:FCFS、SSTF、SCAN/LOOK 的本质与选择
先看最朴素的调度算法。FCFS(先来先服务)逻辑最简单:哪个请求先进来就先服务哪个,不管电梯现在在几楼、往上还是往下。单台电梯、低流量场景没问题,但请求一多,电梯会频繁来回折返,效率很难看。SSTF(最短寻道时间优先)看着聪明——每次选离当前楼层最近的请求——实际上和磁盘调度里的同名算法一样,容易出现「饿死」:远处的请求一直等不到服务。
电梯调度里真正的主流是 SCAN 和 LOOK。SCAN 算法像是扫地:电梯保持当前方向一直走,把这个方向上的请求全部处理完,再掉头处理反方向。LOOK 是 SCAN 的优化版,电梯不需要跑到端点再回头,当前方向没有请求了就直接反向。差那一截「跑到端点」的距离,在高密度楼层里能省不少时间。下面是一个 LOOK 控制器的核心逻辑:
class LookDispatcher: def __init__(self, car): self.car = car self.up_requests = set() # 上行请求楼层 self.down_requests = set() # 下行请求楼层 def step(self): car = self.car # 没有请求时,电梯就近停靠 if not self.up_requests and not self.down_requests: car.direction = 0 car.state = IDLE return if car.direction >= 0: # 当前向上或静止 # 当前方向上方还有上行请求,继续向上 if any(f >= car.floor for f in self.up_requests): car.target = min(f for f in self.up_requests if f >= car.floor) car.direction = 1 # 上方没有上行请求了,尝试上方是否有下行顺路请求 elif any(f > car.floor for f in self.down_requests): car.target = min(f for f in self.down_requests if f > car.floor) car.direction = 1 # 当前方向没有请求,掉头向下 else: car.direction = -1 car.target = max(self.down_requests) else: # 当前向下 if any(f <= car.floor for f in self.down_requests): car.target = max(f for f in self.down_requests if f <= car.floor) car.direction = -1 elif any(f < car.floor for f in self.up_requests): car.target = max(f for f in self.up_requests if f < car.floor) car.direction = -1 else: car.direction = 1 car.target = min(self.up_requests)这段代码里最值得注意的细节是「顺路捎带」的判断:电梯上行时不光处理上行请求,也处理「目标层在下方、但乘客已经站在轿厢里」的请求。这里为了控制长度,只展示了厅站请求的清扫逻辑,轿厢内请求的处理方式更简单——把目标楼层按当前方向插进同一个up_requests或down_requests集合即可。参数上,direction用 1 表示上行、-1 表示下行、0 表示空闲;target是电梯当前计算出的目标楼层,每次状态迁移后重新计算。
LOOK 适合楼层分布均匀、各层请求量相近的写字楼。它的优点是写起来直观、参数少,缺点是它对「某些楼层特别忙」的场景不够敏感——低区人永远多,算法却一视同仁扫地扫过去,低区请求会拖慢整台电梯。
3.2 目的层选梯:把「进电梯再选层」改成「分配好再上电梯」
传统电梯在厅站只有上、下两个按钮,乘客进电梯后才按目标楼层。调度器在乘客进电梯前完全不知道人家要去几楼,只能「先往有请求的方向派台电梯,等乘客进去再决定」。这种模式在算法上叫盲目分配,信息量严重不足。
目的层选梯(Destination Dispatch)改变的是交互方式:乘客在厅站直接输入目标楼层,调度器瞬间就知道「有一个人要从 3 楼去 47 楼」,然后直接分配一台电梯给他,把相同目的地的人合并到同一台电梯。这样做的好处是停站次数显著减少——传统模式下两台电梯分别停 4 楼、7 楼、9 楼,目的层选梯会尽量让一台电梯把 4、7、9 的请求都接收下来。
def dispatch_by_destination(call, elevators): """ call: 包含 origin_floor 和 target_floor 的目的层请求 返回选中的电梯 id """ best_id = -1 best_cost = float("inf") for e in elevators: cost = arrival_time_estimate(e, call.origin_floor) \ + 0.4 * trip_time_estimate(e, call.origin_floor, call.target_floor) if cost < best_cost: best_cost = cost best_id = e.id elevators[best_id].assign(call) return best_id这个函数的成本计算只有两个因子:预计多长时间到乘客所在楼层,以及接了这名乘客后预计要跑多久。第二个因子乘了 0.4 的权重,意思是调度更侧重「让乘客尽快上电梯」,而不是「让路上省时间」。实际项目里这个权重一般取 0.3 到 0.6 之间,取低了电梯会频繁为了顺路而绕远,取高了乘客等待时间会变长。
目的层选梯的仿真建模和传统方式有一个关键差异:乘客对象的生成时机。传统模式下,乘客先产生一个厅站请求,进入电梯后再产生一个轿厢内请求;目的层模式下,乘客一出场就带着完整的目标楼层,调度器立刻分配电梯 id,后续「去哪个厅等」都由这个 id 决定。仿真代码里,我只在乘客对象上多存一个assigned_elevator_id字段,就能完整模拟这套逻辑。注意不要忘了模拟乘客的「服从性」——目的层系统最怕乘客不按分配走,进了别的电梯,这在实际项目中是真实存在的体验问题,但仿真里默认乘客服从分配即可,否则模型会复杂到失控。
3.3 群控与高峰模式:电梯超过 4 台时算法如何协同
单台电梯的调度算法再好,多台电梯一协同就立刻冒出新问题:谁来接哪个请求?分配错了,会出现两台电梯同时冲向 3 楼,然后一台空驶回大厅,另一台满载却没人管的浪费局面。常见的群控策略是「最小等待时间分配」,即每来一个请求,计算所有电梯接它的预计到达时间,选最小的那台。这个策略在低中流量下表现不错,参数也简单——只有「预计到达时间怎么算」这一个问题。
比较实用的一个改进是区域偏爱:给每台电梯设置一个「偏爱楼层区间」,比如 1 号电梯偏爱低区、2 号偏爱中区、3 号偏爱高区,正常运行时请求优先分配给偏爱区间覆盖它的电梯,只有当偏爱电梯忙不过来时,才让其他电梯补位。这个策略本质上是用「固定扇区」换取「响应确定性」,减少电梯来回空跑。
高峰期模式则是另一套参数。早高峰上行压力大,电梯的最佳策略是「空车下行到大厅接人」,甚至提前预留几台电梯停在低区待命;晚高峰则反过来,电梯应该在高区多停留,减少乘客等电梯的时间。仿真里实现高峰期模式不需要改算法,只要做两件事:一是改变乘客的 OD 分布,早上大部分乘客从低区往高区走;二是把调度器的权重参数从「接乘客快」切换到「整体吞吐优先」。
我一般会把高峰模式做成一个独立参数开关,而不是写死到算法里。这样同一套代码,只改乘客生成分布和两个权重参数,就能模拟早高峰、晚高峰、均衡办公、午餐时段四种场景。注意:仿真对比不同调度算法时,必须保证各算法跑在完全相同的客流场景下,否则对比结果根本不成立,这一点后面避坑章节还会细说。
4. 让仿真程序跑起来:事件建模、客流生成与最小骨架
4.1 仿真时钟:逐秒推进与事件驱动的取舍
写电梯仿真,第一个技术选型是仿真时钟怎么走。逐秒推进(时间步长法)最简单:每 0.1 秒或 0.5 秒推进一次,计算每台电梯在这个时间片里的位移、速度、开关门状态,同时检查有没有新乘客到达。事件驱动则只在状态变化时推进时钟,理论效率高,但电梯运动是连续积分过程,乘客到达又是独立随机事件,混在一起写很容易出现「时间倒流」之类的 bug。
我的做法是混合式:主循环用固定时间步长(默认 0.1 秒)驱动电梯运动学计算,乘客到达则用「下次到达时刻」的事件方式插入。这样做的好处是,运动学积分简单稳定,乘客生成的随机性也不会受步长限制。
DT = 0.1 # 仿真步长(秒) SIM_SECONDS = 3600 # 仿真总时长(秒) sim_time = 0.0 next_arrival = 0.0 while sim_time < SIM_SECONDS: # 1. 处理到点到达的乘客 if sim_time >= next_arrival: p = generate_passenger(sim_time, arrival_rate, od_matrix) env.dispatcher.assign(p, env.elevators) next_arrival = sim_time + random.expovariate(arrival_rate) # 2. 推进所有电梯的状态与位置 for car in env.elevators: car.step(DT) # 3. 处理到站乘客的下客与等待队列的上客 env.handle_boarding() sim_time += DT这段主循环里,DT参数直接决定精度。取 0.1 秒时,一台 4m/s 的电梯每个时间片走 0.4 米,平层判断精度足够;取 1 秒就太粗了,电梯可能直接「跳过」目标楼层。但 DT 也不是越小越好,0.01 秒会让仿真速度慢十倍,对于动辄模拟一整天的场景没有意义。arrival_rate是每秒平均到达乘客数,random.expovariate用来生成符合泊松过程的到达间隔。
4.2 客流建模:泊松到达、OD 矩阵与高峰期场景
乘客到达规律,行业里默认用泊松过程描述:乘客是独立到达的,平均到达率稳定,不存在「约好了一起按电梯」的情况。实现上就是生成指数分布的到达间隔,上面主循环里的next_arrival就是这么来的。真正要花心思的是 OD 矩阵——从 i 层出发去 j 层的乘客比例。一栋 80 层的大楼,OD 矩阵就是 80 × 80 的表,早高峰时矩阵的数值集中在「低区出发、高区到达」,午休时又会出现「各层到食堂层」的密集块。
import random def generate_passenger(now, arrival_rate, od_matrix): # 1. 下一乘客到达时刻由指数分布采样 arrive_time = now + random.expovariate(arrival_rate) # 2. 从 OD 矩阵抽样出发层 origin = weighted_sample(od_matrix.origin_weights) # 3. 从 OD 矩阵的对应行抽样目标层 target = weighted_sample(od_matrix.target_weights[origin]) return Passenger(origin=origin, target=target, arrive_time=arrive_time, start_wait=arrive_time) def weighted_sample(weights): total = sum(weights) r = random.uniform(0, total) acc = 0 for i, w in enumerate(weights): acc += w if r <= acc: return i return len(weights) - 1这段代码里arrival_rate的单位是人/秒,0.2 表示每 5 秒来一个人,这是中等办公密度;早晚高峰我会把这个值调到 0.8 到 1.5,模拟大厅排长队的场景。od_matrix.origin_weights是各楼层出发乘客的比例,通常低区权重高,因为大堂、接待、会场都在低区。target_weights[origin]是从该出发层去各目标层的概率分布,早高峰可以做成线性递增——楼层越高权重越大,模拟「大家都去高层上班」的形态。
做客流参数时有个必须养成的习惯:把 OD 矩阵改完以后,先单独打印一行「总乘客数 3600 人,平均行程层数 22.5」这类摘要,确认场景规模符合预期,再跑算法对比。我踩过一次数据异常翻车的坑——OD 矩阵做错,跑了两个小时仿真,最后发现平均行程层数只有 3 层,整个实验等于白做。
4.3 最小骨架:Elevator、Passenger、Dispatcher 三件套
仿真程序不管写多复杂,核心其实就是三个对象:电梯负责运动和载客,乘客负责携带起点、终点、时间戳,调度器负责把乘客分配给电梯。把这三件事分开,算法再怎么换,程序骨架都不用动。
class Elevator: def __init__(self, eid, capacity, max_speed, accel): self.id = eid self.capacity = capacity # 额定载客量,单位:人 self.max_speed = max_speed # m/s self.accel = accel # m/s^2,默认取 1.2 self.floor = 0 # 当前楼层(浮点数,便于平层判断) self.direction = 0 # -1 下行 / 0 空闲 / 1 上行 self.state = ElevatorState.IDLE self.passengers = [] # 轿厢内乘客 self.stop_floors = set() # 计划停靠楼层 def step(self, dt): # 根据当前状态更新速度和位置,并判断是否到达目标楼层 ... class Passenger: def __init__(self, oid, origin, target, arrive_time): self.oid = oid self.origin = origin self.target = target self.arrive_time = arrive_time self.board_time = None # 上电梯时刻,用于计算等待时间 self.leave_time = None # 下电梯时刻,用于计算行程时间 class Dispatcher: def assign(self, p, elevators): # 由具体策略实现:fcfs、look、destination ...Elevator.floor用浮点数而不是整数,是刻意为之——电梯减速到楼层附近时,需要判断「当前速度下能否在目标楼层刹停」,这是连续运动学问题,整数楼层根本表达不了。capacity单位取「人」,配 14 或 21 这种常见值即可,不需要按重量建模,除非你想仿真满载超载。Passenger里的board_time和leave_time是后面统计指标的关键,等待时间 =board_time - arrive_time,行程时间 =leave_time - board_time,这两个时间戳缺失一个,整个指标系统就废了。
4.4 参数分离:把「电梯类型」「调度算法」「客流」三个维度解耦
仿真跑起来以后,你会发现调参的时间远超写代码的时间。所以一开始就要把参数和代码分开,我习惯用 YAML 配置把所有可变项收拢到一起。这样换一个电梯类型标注、换一套算法,只改配置,不碰代码。
building: floors: 90 floor_height: 4.0 elevator: structure: twin # single | twin | double_deck count: 8 capacity: 14 max_speed: 4.0 acceleration: 1.2 door_open_time: 3.0 door_close_time: 2.5 algorithm: name: destination # fcfs | look | destination weight_trip: 0.4 peak_mode: morning # off | morning | evening traffic: arrival_rate: 0.5 od_matrix_file: "morning.csv" total_seconds: 3600配置里最有价值的两个参数是elevator.structure和algorithm.name。前者决定状态机是单厢、双厢还是双层,后者决定调度器走哪套逻辑。我把这两个字段设计成字符串枚举,主程序里通过工厂函数创建对应对象,这样「换电梯类型」和「换调度算法」都只是一行配置的改动。door_open_time和door_close_time看起来不起眼,却对仿真结果影响巨大——每站多 1 秒,高峰期一天下来累积的吞吐差距足以让两个算法分出胜负,所以这两个参数必须单独列出来方便调。
5. 电梯仿真避坑指南:七个让结果不可信的细节
5.1 物理参数与随机数上的坑:加速曲线、随机种子、门时间
坑 1:加速曲线用成矩形波,乘客集体晕梯。现象是电梯每个区间都全程匀速,到站瞬间从 4m/s 直接变 0,速度曲线是方波,乘客等待时间看起来很短但完全不可信。原因是把运动简化为「加速到最大速度然后一直跑」,忽略了电梯运行距离短时根本到不了最大速度。解决方法是三段式速度曲线:匀加速、匀速、匀减速,如果区间距离太短,就取消匀速段,加速到一半直接减速。
def travel_time(distance, v_max, a): # 三段式:加速段 + 匀速段 + 减速段 # 如果距离太短,达不到 v_max,则只有加速段和减速段 d_accel = v_max * v_max / (2 * a) # 加速到最高速度所需距离 if distance >= 2 * d_accel: t_accel = v_max / a t_cruise = (distance - 2 * d_accel) / v_max return 2 * t_accel + t_cruise else: # 加速到一半开始减速,最高速度为 sqrt(a * distance) v_peak = (a * distance) ** 0.5 return 2 * v_peak / a这个函数直接决定仿真里的运行时间精度。注意v_peak的计算:当楼层间距很短时,电梯的实际最高速度达不到v_max,用sqrt(a * distance)算出来的才是真实峰值速度。a取 1.0 到 1.5 之间对乘客体验和能耗都合理,取了 2.0 以上,你的仿真里乘客会体验到过山车一样的推背感,等待时间也会被不真实地缩短。
坑 2:随机种子没固定,同一个场景跑三次三个结果。现象是两次运行之间,乘客到达时间完全不同,导致算法 A 跑出来比 B 好,换一次运行顺序结论就反了。原因是仿真里用了大量random调用,每次运行生成的随机序列都不一样。解决方案一句话:在主程序入口处固定随机种子。
import random random.seed(42) # 固定种子,让每次运行的乘客序列完全一致 # 注意:如果乘客生成和算法内部都用了 random, # 一定要在乘客生成前先调用一次 random.seed()固定种子之后,还有一个隐藏细节:如果你改了调度算法,算法内部如果也用了随机数(比如随机初始化),那乘客序列也会跟着变。严谨的做法是乘客生成用独立的random.Random(seed)实例,算法内部用另一个实例,两者互不干扰。我一般会在配置里加一个traffic_seed字段,这样对比算法时能保证所有算法跑在完全相同的客流上。
坑 3:开门时间被当成常数忽略。现象是仿真结果里电梯单次行程时间比真实设备快 20% 以上,平均等待时间被严重低估。原因是把开关门当成瞬发事件,没有计入乘客进出轿厢的时间。解决方法是把开关门拆成三段:开门、上下客、关门。上下客时间按人数估算,每人 0.8 秒,开门和关门各按配置的 2 到 3 秒计算。真实电梯的开关门动作快慢还受门机老化影响,但仿真里固定值就够用了,重点是别把它省掉。
5.2 模型完整性与统计口径上的坑:满载、双厢互斥、高峰策略与统计口径
坑 4:双厢电梯仿真里出现「幽灵空跑」。现象是两台轿厢在同一井道里互相穿过,或者同时停靠同一层,乘客分配结果看起来吞吐很高实际上物理上根本不可能。原因是双厢模型里,两个轿厢各自独立运行,缺少「同井道安全间距」约束。解决方法是每步推进后检查两个轿厢位置,间距小于安全阈值(通常 2 米)时,让后方轿厢减速等待。
def check_twin_safe(twin_car_a, twin_car_b, min_gap=2.0): gap = abs(twin_car_a.floor - twin_car_b.floor) if gap < min_gap: # 后方轿厢减速或停止,等待前方轿厢离开安全区间 behind = twin_car_a if twin_car_a.floor < twin_car_b.floor else twin_car_b behind.direction = 0 behind.state = ElevatorState.IDLE注意min_gap的单位是楼层数,2.0 代表两层。实际双子电梯的安全间距还跟两个轿厢的速度有关——高速追近时的安全距离更大——但仿真里先用固定值,逻辑上就把「穿模」问题解决了。要再精确一点,可以写成min_gap = 2.0 + 1.5 * behind.speed,让间距随速度动态变化。
坑 5:满载忽略导致「电梯来了却上不去」。现象是电梯响应了请求、停在乘客面前,但轿厢早就满了,乘客继续等多了一轮,而仿真里乘客却「成功挤进去」了。这会让等待时间和满载率两个指标同时失真。解决方法是给轿厢加一个满载判断:轿厢内人数达到额定载客量的 80% 以上时,只下客、不停站接新客。
def can_board(car): # 满载阈值取额定载客量的 80% return len(car.passengers) < int(car.capacity * 0.8)0.8这个满载阈值不是随便拍的。实际电梯设计允许短暂超载到 110%,但超过 80% 时多停一层往往得不偿失——乘客进不来几个,却浪费了所有人的时间。所以仿真里把 80% 设为「不再响应顺路请求」的阈值,低于这个值正常停靠,高于则只放行目标层在这站或更远方向的乘客。这样既贴近真实,也不会因为满载建模过于复杂拖慢仿真速度。
坑 6:高峰期策略反向优化,越优化等待时间越长。现象是早高峰启用了「空车下行到大厅接人」策略,却发现大厅排队的队伍不减反增。原因是策略只在算法层改了目标楼层的分配,但电梯的调头逻辑没有同步调整——电梯下行接人时,中途被低区的顺路请求截走,去了别的楼层,大厅的车全被「拐跑」了。解决方法是高峰模式下锁死调头逻辑:电梯下行接人途中不响应任何顺路请求,直达大厅;只有完成了这个引导任务,才恢复普通调度。
坑 7:只看平均等待时间,被少数幸运乘客拉低指标。现象是 A 算法平均等待 40 秒,B 算法平均等待 45 秒,看似 A 好,画出分布曲线才发现 B 的 90 分位等待时间只有 70 秒,A 却高达 120 秒——A 的「平均」是被一小撮 10 秒内上梯的幸运乘客拉低的。解决方法是统计指标必须以「平均 + 90 分位 + 最大」三个数一起看。我习惯在仿真结束后输出一张统计表,平均、中位数、90 分位、最大等待时间和最大行程时间全列出来,任何算法优化都必须同时改善平均和 90 分位,否则视为无效优化。
6. 验证仿真可信度:先跑 FCFS 基准,再看 90 分位指标
仿真程序跑通之后,最难的一步不是让它跑,而是让别人相信「这个仿真结果是可信的」。我能想到的最有效也最朴素的方法,是先跑一个基准策略,再跑你的优化策略,让两个结果自己说话。具体做法是:固定客流场景,先把调度器设为 FCFS,记录全部指标;再换成 LOOK、目的层选梯,保持其它参数一字不动。对比时不要只盯着平均等待时间,把 90 分位、满载率、单次行程停站数也列出来。
我一般用这样的表格记录每一次实验:
| 指标 | FCFS 基准 | LOOK 清扫 | 目的层选梯 |
|---|---|---|---|
| 平均等待时间(秒) | 62.4 | 43.8 | 31.2 |
| 90 分位等待时间(秒) | 118.0 | 79.5 | 56.3 |
| 平均行程时间(秒) | 41.7 | 37.2 | 34.8 |
| 平均停站次数 | 6.8 | 4.5 | 3.2 |
| 高峰满载率(%) | 87 | 84 | 79 |
注意这里的数值只是示意,真正跑出来以你自己的参数为准。这个对比表的意义在于建立一条「基准线」:任何算法说自己的指标好,都得先跑过 FCFS 再看提升了多少。有次我做过一个自认为完美的群控策略,画了一张漂亮的曲线图,某导师看了一眼只问了一句「你的基准呢?」——我把基准跑完才发现,所谓优化只是把平均等待时间从 62 秒降到 55 秒,90 分位反而更差了,之前的「性能测试」全建立在没有参照系的幻觉上。
进阶一步,如果你想验证电梯类型差异,就把配置里的elevator.structure从single改成twin再改成double_deck,其余参数不动,同样跑一遍对比。这一步的价值在于帮你回答「这栋楼该选什么电梯结构」——而不是靠设备厂商的销售手册。仿真给出的数据也许不能替代真实工程测试,但至少能让方向性的决策不再靠拍脑袋。
最后送你一个我的个人习惯:每次跑完仿真,把配置文件、随机种子、指标摘要一起保存,按照「日期_策略_场景」命名。三天后回头复查时,你会发现那组「当时觉得完美」的数据,大概率是在某个参数没对齐的情况下跑出来的,幸好有存档才能定位问题。这个「先存配置再谈结论」的习惯,帮我避免过无数次返工。希望帮到你。
本文还有配套的精品资源,点击获取