☰
Java离散事件电梯仿真:派梯策略与候梯时间评估
2026/10/30 1:14:01 网站建设 项目流程

简介:ElevatorSimulation 是一份面向Java初学者与调度算法学习者的摩天大楼电梯系统仿真代码包,围绕单向/双向、观光、高速与消防等电梯类型,以及FCFS、最近楼层优先、最少行程时间、预测调度和多梯群控等策略展开,可用于课程实验、毕业设计选题和面向对象练手。压缩包共75个文件、约70KB,以25个Java源码为核心,配合13个XML配置及prefs、txt等工程与说明文件,覆盖电梯状态、乘客呼叫、客流生成、调度器和楼宇管理等模块,并通过单一流量生成器构造随机请求,便于在受控规模下复现实验、观察不同策略的结果差异。已有403人学习,读者可据此理解乘客请求建模、电梯状态流转与算法评估方法,借助现有类骨架比较等待时间与运行效率,积累Java数据结构与算法落地经验。

1. 从早高峰的 47 秒等待说起:这套 Java 电梯仿真到底在算什么

某栋 40 层写字楼,早高峰从一层到 28 层平均要等 47 秒,物业坚持说电梯没问题,厂家说配置合规——最后能拿上台面说话的,只有一张按秒算出来的候梯时间分布图。ElevatorSimulation 就是干这件事的:一套用 Java 写的摩天大楼垂直交通仿真工程,把楼宇、轿厢、乘客建成可计算对象,用离散事件时钟把八个高峰小时压缩成几十秒跑完,输出平均候梯时间、P95 等待、5 分钟输送能力和能耗代理指标。它不画电梯上下跑的小动画,核心价值是让单轿厢、双层轿厢、空中大堂穿梭梯这些不同电梯类型,和 FCFS、LOOK、目的层派梯这些调度算法,在同一组客流下打出可比的分数。适合做梯控联调的工程师、要交课程设计的学生,以及想验证分区方案到底省不省时间的人。

2. 拆开工程结构:楼宇模型、轿厢状态机与离散事件时钟

拿到一个仿真工程,最先看的不是入口类,而是它怎么表达时间。电梯仿真的所有坑几乎都埋在时间语义里:开关门用秒还是用 tick、乘客上下车算不算占用、轿厢到达同一时刻的多个事件谁先执行。这一章先把对象模型和时钟拆开,再跑通第一条命令。

2.1 三类核心对象:Building、Elevator、Passenger 各自背什么状态

工程按sim.model、sim.core、sim.dispatch、sim.stats、sim.scenario分包,模型层只有三个主角。Building持有楼层列表、层高、分区(zone)、空中大堂换乘点以及电梯组(ElevatorBank);每个ElevatorBank是若干台参数相同的轿厢,共享一个派梯器。Elevator记录绝对高度位置、速度、方向、门状态、轿内乘客和选层队列。Passenger只记录时间线和起讫层。

先把乘客写对,后面所有指标才有出处:

public final class Passenger { public enum Status { WAITING, RIDING, ARRIVED, ABANDONED } final long id; final double arriveTime; // 进入候梯厅的时刻,单位 s final int origin; final int dest; final double weightKg; // 用于按载重而不是按人头限员 double assignTime = Double.NaN; // 被派梯器分配给某台轿厢 double boardTime = Double.NaN; // 最后一步踏入轿厢的时刻 double alightTime = Double.NaN; // 完全离开轿厢的时刻 int servedByCar = -1; int legs = 1; // 有空中大堂换乘时会变成 2 Status status = Status.WAITING; boolean isTimelineComplete() { return !Double.isNaN(arriveTime) && !Double.isNaN(alightTime); } }

这里有两个刻意的选择。assignTime和boardTime分开记,是因为派梯策略的差异往往体现在“叫了车但车没来”这段,而不是在轿厢里;只统计乘梯时间会得出“所有算法差不多”的错误结论。用weightKg而不是人数限员,是为了让 1000kg/13 人和 1600kg/21 人这两种常见规格在同一套代码里可比;超载判定按重量走,人数只作为辅助展示。

轿厢的状态机则要挡住“瞬移”这个经典翻车点:

public final class Elevator { public enum State { IDLE, ACCEL, CRUISE, DECEL, DOOR_OPENING, DOOR_OPEN, DOOR_CLOSING } final int id; final ElevatorSpec spec; // vMax、aMax、额定载重、开门/关门/停留时间 double positionM; // 以一层厅门地坎为 0 的绝对高度,不是楼层号 double velocity; // m/s,向上为正 Direction dir = Direction.NONE; State state = State.IDLE; final List<Passenger> onboard = new ArrayList<>(); final TreeSet<Integer> carCalls = new TreeSet<>(); // 轿内选层,天然有序 double loadKg() { return onboard.stream().mapToDouble(p -> p.weightKg).sum(); } boolean canAccept(Passenger p) { return loadKg() + p.weightKg <= spec.ratedLoadKg(); } }

positionM用米而不是楼层索引,是因为层高 3.5m 和 4.2m 的楼,加速度对行程时间的影响完全不同;用楼层号做单位会把运动学抹平。carCalls用TreeSet而不是ArrayList,是给 LOOK 类算法留的:顺向经过的层天然按序遍历,不用每次排序,也顺手规避了HashMap迭代顺序导致的不可复现。

2.2 时间推进:固定 tick 与离散事件,怎么选

市面上的课程设计大多用固定 tick:每 10ms 推进一次,所有轿厢更新位置。写法简单,但在电梯场景里有两个硬伤。一是精度浪费——开关门 1.2 秒要跑 120 次循环,四十层楼三十台梯跑一天客流,纯空转就够喝一壶;二是事件对齐——乘客到达、门到位、超时取消这些时刻并不落在 tick 边界上,四舍五入之后等待时间会出现系统性偏差,几秒钟的误差在 P95 上会被放大。

这套工程走离散事件:事件按时间进优先队列,时钟直接跳到下一个事件。第一个坑是同一时刻的多个事件顺序不确定,解决办法是给每个事件编一个单调递增的序号做二级排序:

public final class EventLoop { private record Event(double time, long seq, Runnable action) {} private final PriorityQueue<Event> queue = new PriorityQueue<>( Comparator.comparingDouble(Event::time).thenComparingLong(Event::seq)); private double now = 0.0; private long seq = 0L; public double now() { return now; } // delay <= 0 也允许,但会走同一时刻队列,靠 seq 保证 FIFO public void schedule(double delay, Runnable action) { queue.add(new Event(now + Math.max(0.0, delay), seq++, action)); } public void run(double endTime) { while (!queue.isEmpty()) { Event e = queue.peek(); if (e.time() > endTime) break; // 不允许越过仿真终点 queue.poll(); now = e.time(); e.action().run(); } now = endTime; } }

三个参数值得说清楚。endTime是硬边界,越过终点的事件一律丢弃,否则“最后一名乘客上电梯之后”的收尾事件会把统计窗口拉长。Math.max(0.0, delay)把负延迟压成 0,防止某个模型算错时间把时钟往回拨,这类错误一旦发生就是死循环。seq保证同一时刻先安排的事件先执行——门开完再上客,而不是上客完再开门,顺序反了会直接让所有候梯时间归零。

提示:比较时间不要用==。如果某个模型要判断“两事件是否同时”,用Math.abs(a - b) < 1e-9这种容差写法,浮点累加之后的相等判断靠不住。

2.3 第一次跑通:配置文件、命令行和输出目录

模型和时钟就位之后,工程用一份 JSON 描述场景。常见做法是把楼宇、电梯组、客流模式、派梯策略四块分开写,改方案时只动其中一块:

{ "name": "up-peak-40f", "building": { "floors": 40, "floorHeightM": 4.0, "population": 1600, "skyLobbyFloors": [] }, "banks": [ { "id": "low", "servesFloors": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "cars": 6, "ratedLoadKg": 1600, "vMax": 2.5, "aMax": 1.0, "doorOpenS": 1.2, "doorCloseS": 1.4, "dwellS": 3.5, "dispatcher": "LOOK" } ], "traffic": { "pattern": "UP_PEAK", "percentPopulationPer5Min": 12.0, "startS": 0, "endS": 3600 }, "seeds": [20240501] }

字段含义:servesFloors决定这个梯组能停哪些层,分区方案就是改这一个数组;population是总人数,配合percentPopulationPer5Min换算出到达率,常见取值 10~15 对应重度早高峰;dispatcher是字符串到派梯器实现的映射键,后面加策略只要往注册表里塞一行。

跑起来就两步:

# 打包,跳过测试是为了先看结果,正式跑实验前务必把测试跑一遍 mvn -q -DskipTests package # 单场景:指定场景文件、随机种子、输出目录 java -jar target/elevator-sim.jar \ --scenario scenarios/up-peak-40f.json \ --seed 20240501 \ --out out/up-peak-40f

--seed必须显式传,工程内部不允许出现无参new Random(),这个约束后面排查不可复现问题时会救命。--out目录下会落三个文件:summary.csv(每场景一行汇总指标)、passengers.csv(每人一条时间线)、events.log(可选的调度事件流,只在传--trace时生成)。调算法时先看passengers.csv,比看汇总值有用得多——分布形状不对,均值再漂亮也没意义。

3. 电梯类型与调度算法:从 SCAN 到目的层派梯

同一栋楼里,穿梭梯、低区客梯、货梯的运行约束根本不是一回事,把它们塞进同一个派梯器是很多仿真失真的源头。这一章先按电梯类型划清边界,再给五种派梯策略的最小实现,最后落到参数怎么填。

3.1 先分清电梯类型,别拿货梯参数跑客梯场景

工程里用ElevatorSpec描述类型差异,主要区分维度是停站集合、速度、载重和是否允许换乘。常见的几类:

类型停站约束典型速度载重/人数仿真里的关键差异
低区客梯只停本区楼层1.75~2.5 m/s1000~1600kg停站少,行程时间对加速度敏感
高区客梯只停高区,常穿低区不停2.5~4.0 m/s1350~1600kg穿越层不停,位置计算要跨区
穿梭梯(空中大堂)起点终点固定3.0~6.0 m/s1600~2000kg客流是批量到达,不是泊松
双层轿厢只能停成对楼层2.5~4.0 m/s2×1600kg两层同时上下客,停站数减半
货梯全楼层,长停留1.0~1.75 m/s2000kg+装卸时间远大于运行时间
消防梯全楼层,优先级最高2.5 m/s按规范抢占式调度,需要能打断普通派梯

选型时最容易翻车的是双层轿厢:它只能服务成对的楼层,一层是奇数层另一层是偶数层,配置里必须给servesFloors写成[[1,2],[3,4],...]这种配对形式,而不是平铺的楼层数组。分区方案里如果低区是 1~10 层、高区 11~40 层,双层轿厢的配对就会在分区边界断掉,这时候要么调整分区边界,要么给边界层单独配一台单层梯。

3.2 五种派梯策略的最小实现

派梯器统一成一个接口,输入一次呼叫和当前所有轿厢的快照,输出选中的轿厢。策略之间只换实现,不动模型层:

public interface Dispatcher { // call:呼叫楼层、方向、发起时刻;bank.cars() 返回不可变快照 Optional<Elevator> select(ElevatorBank bank, Call call, double now); }

LOOK(顺向截梯)是最常用的基准策略,核心是“同向且还没越过呼叫层的车里挑成本最低的,没有就挑空闲车里最近的”:

public final class LookDispatcher implements Dispatcher { private static final double STOP_PENALTY_S = 6.0; // 一次中途停靠的平均代价 @Override public Optional<Elevator> select(ElevatorBank bank, Call call, double now) { Elevator best = null; double bestCost = Double.MAX_VALUE; for (Elevator car : bank.cars()) { if (!car.serves(call.floor())) continue; double dist = Math.abs(car.positionM - bank.heightOf(call.floor())); double cost; boolean sameDirection = car.dir() == call.direction(); boolean willPass = sameDirection && ( (call.direction() == Direction.UP && car.positionM <= bank.heightOf(call.floor())) || (call.direction() == Direction.DOWN && car.positionM >= bank.heightOf(call.floor()))); if (car.state() == Elevator.State.IDLE) { cost = dist / car.spec().vMax(); // 空车:纯运行时间 } else if (willPass && car.canAccept(call)) { cost = dist / car.spec().vMax() + car.pendingStopsBefore(call.floor(), call.direction()) * STOP_PENALTY_S; } else { cost = dist / car.spec().vMax() + 30.0; // 反向或满载:加重惩罚 } if (cost < bestCost) { bestCost = cost; best = car; } } return Optional.ofNullable(best); } }

STOP_PENALTY_S是这套代码里最需要按楼调的量。6 秒对应的是“减速进站 + 开门 + 3 人上下 + 关门”的中位值,层高 4m、速度 2.5m/s 的楼里这个数合适;4.0m/s 的高区梯因为减速段更长,实际代价接近 7~8 秒,调小了会让派梯器过度乐观地认为“顺路带一脚不费事”。+30.0是反向惩罚,不是物理量,它的作用是让空闲车永远比反向车优先,避免出现“车刚过站还回头接人”的鬼故事。

目的地派梯(destination dispatch)的差别在入口:乘客在厅外先输入目的层,派梯器按目的层分组,把同组乘客塞进同一台车,停站数因此下降:

public final class DestinationDispatcher implements Dispatcher { @Override public Optional<Elevator> select(ElevatorBank bank, Call call, double now) { // call 里带 destFloor,先映射到目的层所属的“停站簇” int cluster = call.destFloor() / 4; // 每 4 层一簇,可在配置里改 return bank.cars().stream() .filter(c -> c.serves(call.floor()) && c.serves(call.destFloor())) .filter(c -> c.assignedClusters().contains(cluster) || c.assignedClusters().isEmpty()) .filter(c -> c.canAccept(call)) .min(Comparator.comparingDouble(c -> c.projectedDelaySeconds(call, now))); } }

簇宽是关键参数:4 层一簇意味着一次停靠能覆盖 4 个目的层,停站数下降约三成;簇太宽会让乘客在轿厢里绕路,全程时间反而上升。projectedDelaySeconds需要模拟“把这次呼叫插入现有路线后,车上已有乘客的延误增量”,工程里给了一个简化实现,只算新增停站数与绕行距离,够用来比较策略优劣。

除了这两种,FCFS(先到先服务,谁先叫谁上)、SSTF(最近车优先)、ETA(预估到达时间最小)也各有一个实现类。经验是:低层数、低客流时 SSTF 表现不差且实现最省事;层数上到 25 层以上、客流超过 10%/5min,LOOK 和目的地派梯的差距才拉开。

3.3 运动学参数:速度、加速度、开关门时间怎么填才不飘

参数填错,算法比不出高低。下面这张表是工程里ElevatorSpec的字段,取值区间来自常见配置:

参数字段典型区间调大的后果
层高floorHeightM3.5~4.2 m单层行程变长,短距离运行更吃亏
最大速度vMax1.75~6.0 m/s短程区间几乎无收益,只增加加减速距离
加速度aMax0.8~1.2 m/s²超过 1.2 乘客体验差,仿真里表现为停站时间被低估
开门时间doorOpenS1.0~1.5 s直接抬高每个停站成本
关门时间doorCloseS1.2~1.8 s同上
停站停留dwellS3.0~6.0 s高峰期被反复按开门键,实际值偏大
单人换乘transferPerPaxS1.0~1.5 s与载客量相乘后是停站时间主项

有一个反直觉的点值得单独说:层高 4m、加速度 1.0 m/s² 时,轿厢加速到 2.5m/s 需要 3.125m,减速又要 3.125m,两者相加 6.25m 已经超过层高。也就是说单层运行时它根本到不了额定速度,实际是三角形速度曲线。这意味着把vMax从 2.5 调到 4.0,在低区短程场景里对行程时间的改善接近于零,反而会让派梯器误判“这台车很快”。我在标定阶段会先跑一组纯运行测试,输出每层的实测行程时间,和公式对不上就说明速度曲线实现有问题。

注意:dwellS设成 0 是很多人为了“加快仿真”干的蠢事。停留时间归零会让同一时刻堆积大量事件,看起来只是数字变小,实际上把开关门竞争、超载拒绝这些行为全抹掉了,结论完全不可用。

3.4 分区与空中大堂:高层建筑的分组策略

40 层以上基本都要分区:低区、中区、高区各一组梯,每组只停本区。分区数是权衡——区数多了每组停站少、行程快,但换乘次数上升、每组的井道利用率下降。工程里给的默认规则是按“每组服务的总人数 / 每组 5 分钟输送能力”来试,先跑 2 区、3 区、4 区三个方案,看哪个的 P95 候梯时间最低。

空中大堂是另一层结构:穿梭梯把人从首层批量送到 20 层或 30 层的空中大堂,再换乘本区电梯。模型上,乘客的legs变成 2,第一段的终点是空中大堂,第二段从大堂出发。统计时必须把两段合并成一次“全程时间”,否则会把换乘等待重复计算或者直接漏掉:

// 换乘场景下,全程时间 = 第二段到达 - 第一段出发,中间的大堂等待必须计入 double journeyTime = passenger.legs == 2 ? secondLeg.alightTime - firstLeg.arriveTime : passenger.alightTime - passenger.arriveTime;

穿梭梯的客流不是泊松:它跟着本区电梯的发车节奏走,一班车到站会带来十几个人同时涌向本区梯厅。工程里用“批量到达”模式处理,traffic.pattern设成SKY_LOBBY,到达时间按穿梭梯的到站时刻加一个小的抖动分布生成。这个细节不做,空中大堂方案在仿真里会显得异常美好,因为模型把本区梯厅的瞬时拥堵平滑掉了。

4. 指标统计与场景回放:把“感觉慢”变成可比较的数字

算法改完,得有一组数能证明改好了。这一章把指标定义、客流生成和批量实验串起来,重点是让不同策略的对比站得住脚。

4.1 乘客时间线:五个时间戳决定所有指标

所有指标都从乘客的时间戳里算,不另设计数器,这样口径永远一致:

指标计算式用途
候梯时间 AWTboardTime - arriveTime乘客感知最强,也是投诉的主要来源
乘梯时间alightTime - boardTime反映绕行和停站数
全程时间alightTime - arriveTime跨梯组对比的唯一公平口径
P95 等待候梯时间 95 分位均值会掩盖长尾,这个数才是体验底线
5 分钟输送能力高峰 5 分钟内送达人数 / 总人数楼宇选型阶段的核心指标
放弃率ABANDONED人数 / 总人数等待超过阈值的乘客比例
启动次数轿厢从静止进入加速的次数能耗代理指标,也反映停站数

百分位不能拍脑袋取,要用排序后线性插值:

public static double percentile(List<Double> sorted, double p) { if (sorted.isEmpty()) return Double.NaN; double idx = (sorted.size() - 1) * p; // p 取 0.95 int lo = (int) Math.floor(idx); int hi = (int) Math.ceil(idx); if (lo == hi) return sorted.get(lo); return sorted.get(lo) + (idx - lo) * (sorted.get(hi) - sorted.get(lo)); }

一个容易忽略的点:长等待乘客如果一直没上电梯,boardTime是NaN,直接过滤会让 P95 变好看。正确的做法是给等待设一个放弃阈值(常见 90 或 120 秒),超时标ABANDONED,统计时把他们的等待时间按阈值计入分母。这批人是体验最差的,删掉他们等于自欺欺人。

4.2 客流生成:泊松到达与四种客流曲线

客流用泊松过程生成,到达间隔服从指数分布,用逆变换法一行就够:

public List<Passenger> generate(TrafficSpec spec, Random rnd) { List<Passenger> out = new ArrayList<>(); double lambda = spec.population() * spec.percentPer5Min() / 100.0 / 300.0; // 人/秒 double t = spec.startS(); long id = 0; while (true) { // 逆变换:-ln(U)/lambda,U 必须落在 (0,1],nextDouble 可能返回 0 double u = 1.0 - rnd.nextDouble(); t += -Math.log(u) / lambda; if (t > spec.endS()) break; out.add(buildPassenger(id++, t, spec, rnd)); } return out; }

percentPer5Min是行业里描述客流强度的习惯单位,12 表示每 5 分钟有总人数 12% 的人进入系统,换算成秒级到达率就是除以 300。四种典型模式在buildPassenger里分叉:UP_PEAK九成以上从首层进、目的层均匀分布;DOWN_PEAK反过来;INTERFLOOR起讫层都在中间层随机;TWO_WAY上下各半。目的地层不要用均匀分布,实际楼宇里 5~8 层的到访密度明显更高,工程里用一个可配置的权重表,默认给首层附近的办公层加权。

4.3 批量实验与 CSV 导出

单次运行的数字不算数,同一配置换 30 个种子跑出来的均值和置信区间才算。批量跑的时候种子必须由外层显式给出,内层所有随机源都从这一个种子派生:

public static void main(String[] args) { Scenario scenario = ScenarioLoader.load(Path.of(args[0])); try (CsvWriter csv = new CsvWriter(Path.of(args[1], "sweep.csv"))) { csv.header("seed", "avgWait", "p95Wait", "avgJourney", "hc5", "abandonRate"); for (long seed : scenario.seeds()) { Simulation sim = new Simulation(scenario, new Random(seed)); // 唯一随机源 Result r = sim.run(scenario.endS()); csv.row(seed, r.avgWait(), r.p95Wait(), r.avgJourney(), r.handlingCapacity5Min(), r.abandonRate()); } } }

hc5的算法是“高峰 5 分钟窗口内送达人数 / 总人数”,窗口要滚动取最大值,不能只取第一个 5 分钟,否则首层还没排满队的时候数据偏乐观。跑完 30 个种子后,用均值 ± 1.96×标准误/√n 给置信区间,两个策略的区间不重叠才敢说“这个更好”。我见过太多只跑一次就下结论的方案,换个种子结论就反过来。

5. 避坑与排查:结果不对,先看这五处

仿真跑出来的数不对劲,九成不是算法写错,而是模型假设和统计口径出的问题。下面五条都是实际调过的。

5.1 平均候梯时间很漂亮,P95 却高得离谱

现象:某个派梯策略 AWT 只有 22 秒,比基准低 8 秒,但 P95 到了 140 秒,比基准还差。

原因:均值被大量短等待乘客拉低,长尾被稀释。常见诱因是派梯器把整批乘客塞给同一台车,前面的人很快走,后面的人无限等;另一种是统计里把超时放弃的乘客直接剔除,分母变小。

解决:把 P95 和放弃率作为硬约束,均值只作参考。派梯器里加一条“单台车连续被分配超过 N 次则强制跳过”的规则,N 取 3~5;统计侧把放弃乘客按阈值计入。改完之后 AWT 通常会小幅上升,P95 明显下降,这才是真的变好。

5.2 仿真秒退,或者卡在同一时刻不动

现象:日志里时间戳一直是t=312.4000,刷了几十万行;或者启动后一秒跑完一天,结果里所有行程时间都是 0。

原因:前者是零时长事件循环——某个 handler 在now不变的情况下又调度了一个 0 延迟事件,比如“门开完立刻关门”但门状态没更新,状态机在两个状态之间来回弹。后者是事件被批量丢弃,通常是endTime传成了 0,或者时间单位混用(配置写毫秒、代码按秒读)。

解决:给事件循环加一个同刻事件计数上限,同一now下超过 10000 个事件就抛异常并打印调用栈;配置文件里所有时间字段统一以S结尾命名,加载时做一次单位断言。

5.3 改了额定载重,指标一动不动

现象:把ratedLoadKg从 1000 改到 1600,所有指标连小数点后三位都没变。

原因:派梯器在分配呼叫时只判断了方向和停站,没判断剩余容量,乘客一旦被分配就绑死在那台车上;或者canAccept用的是人数判断,而乘客体重全默认 75kg,重量约束永远不触发。

解决:派梯时过滤掉剩余容量不足的车,同时在boardTime那一刻再做一次二次校验,装不下就退回候梯厅并重新派梯。乘客体重按 60~90kg 的分布生成,别用常数,否则超载逻辑形同虚设。

5.4 分区之后输送能力涨了,乘客却更不爽

现象:三区方案比单区方案的 5 分钟输送能力高 15%,但全程时间的 P95 反而更差。

原因:分区减少了停站,但增加了换乘。换乘乘客在大堂的等待时间没被计入“候梯时间”,只体现在全程时间里;如果统计口径只报候梯时间,就会得出“分区更好”的错误结论。

解决:所有对比统一用“门到门全程时间”,换乘等待显式累加;同时在报表里单独列一列换乘次数分布,让决策者看到代价在哪。

5.5 同一份配置跑两次,结果不一样

现象:不改任何东西重跑,AWT 差了 0.3 秒。

原因:某个地方用了无参new Random(),或者用HashMap遍历轿厢导致派梯平局时选择不稳定,又或者用了并行流收集结果但没做有序归并。

解决:全工程只允许一个随机源,从--seed派生;轿厢列表按 id 排序后再遍历,TreeSet、LinkedHashMap替代无序容器;并行只用在场景级(每个种子一个任务),任务内部保持单线程。加一个回归测试:固定种子跑两遍,断言summary.csv完全一致。

6. 进阶:用参数扫描找到派梯策略的拐点

单点对比只能告诉你 A 比 B 好,说不出好在什么条件下。真正有用的是参数扫描:固定客流,扫速度和载重,看哪个策略在什么区间翻转。

我一般扫三个维度:vMax(1.75 / 2.5 / 4.0)、ratedLoadKg(1000 / 1600)、派梯策略(LOOK / 目的地派梯),客流固定 12%/5min、40 层三区。外层串行、内层每个种子一个任务,30 个种子取均值,跑完大约几分钟:

List<Scenario> grid = new ArrayList<>(); for (double v : new double[]{1.75, 2.5, 4.0}) { for (int load : new int[]{1000, 1600}) { for (String d : new String[]{"LOOK", "DESTINATION"}) { grid.add(base.copyWith(v, load, d)); // copyWith 只改这三项,其余保持不变 } } } grid.parallelStream().forEach(s -> { double sum = 0, sumP95 = 0; for (long seed : s.seeds()) { Result r = new Simulation(s, new Random(seed)).run(s.endS()); sum += r.avgJourney(); sumP95 += r.p95Wait(); } // 并行流里禁止写共享集合,结果按场景名落独立文件,最后再合并 writeRow(s.name(), sum / s.seeds().size(), sumP95 / s.seeds().size()); });

跑完拿到的示意结果是这样(数字仅用于说明趋势,实际值取决于楼宇配置):

速度载重策略平均全程P95 候梯结论
1.751000LOOK96.2 s118 s基准
2.51000LOOK88.4 s104 s提速收益明显
4.01000LOOK87.1 s101 s收益趋平,短程到不了额定速度
2.51600LOOK84.9 s96 s载重比提速更划算
2.51600目的地79.3 s88 s目的地派梯在密集客流下占优
4.01000目的地78.6 s87 s与上一行差距在置信区间内

两个拐点值得记住。速度从 2.5 提到 4.0,收益基本消失,因为在 4m 层高的低区根本跑不到 4.0;载重从 1000 提到 1600,收益比提速大,因为它直接减少了“满载跳过”造成的二次等待。这就是为什么评估方案时我坚持先看载重和派梯策略,最后才看速度。

跑扫描还有一个副产品:events.log里能捞出一批“同一台车连续三次被跳过”的时段,把它和客流曲线对一下,往往就是派梯器的惩罚系数需要调的地方。

血泪教训是早期我图省事,直接在Elevator里写死了一个静态计数器统计启动次数,结果并行跑场景时所有场景的数字互相污染,一整天实验全废。从那以后我每次加统计字段都强制走一遍“固定种子跑两遍、结果必须逐字节一致”的回归,宁可慢十分钟,也不要有第二次重跑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询