记 VRP 项目

/ coding

近期做了一个 vrp项目,整理其核心建模思路与“技巧点”:


1. 数据与距离矩阵:先把“输入干净化”

1.1 只保留距离矩阵中存在的节点

在构建模型前,先用距离矩阵的 start/end 并集作为 valid_nodes,把 Excel 站点表中不在矩阵里的点剔除(避免后面边缺失导致大量不可行/惩罚边)。

这一段是“工程上最值钱”的预处理:把不可控的数据缺失尽早暴露,并把问题从“求解器坏掉”变成“输入数据缺边”。

1.2 统一距离/时长的单位,并对 bike 做速度缩放

脚本把矩阵中的 distance(km)统一转成米,并把 duration 转为秒;同时 bike 模式把 duration 除以 2(等价于“bike 更快/更慢”取决于你矩阵的定义)。

建模技巧:


2. 多出发点的关键:VirtualDepot + 边的“最短真实出发点”映射

业务上可能有多个“出发点”(站点类型为 出发点),但在求解器里如果把它们都建成真实 depot,会显著增加模型复杂度(车辆类型、流量约束等都会膨胀)。脚本采用了更“工程化”的技巧:

2.1 用一个 VirtualDepot 代表“从任意真实出发点出发”

当没有自定义车辆时,车辆统一从 VirtualDepot 出发(m.add_depot(name="VirtualDepot")),然后通过边的代价把它“投影”到离目标点最近的真实出发点。

2.2 VirtualDepot -> client 的边:取所有真实出发点到该 client 的最小距离

这是核心技巧:当边的一端是 VirtualDepot 时,不直接查 (VirtualDepot, code),而是在所有真实出发点集合里取最小距离。

建模上的意义:

2.3 输出时把 VirtualDepot 还原成“最近真实出发点”

求解时用 VirtualDepot,但输出路线时需要写清楚车到底从哪个仓/点出发。脚本在导出 merged 结果时,会对每条 route 找一个“锚点”(第一个非 VirtualDepot 的真实点),再用同样的最小距离逻辑找到最佳真实出发点,写回到结果 Excel。


3. 是否回场:DummyEnd 实现开放式路线(open route)

对于 bike 阶段,脚本经常用 return_to_depot=False,意味着不强制回 s339(可能更贴近“接驳后由 car 继续”的业务)。实现方式是创建一个 DummyEnd depot,并对所有 to == DummyEnd 的边设置 distance=0,duration=0,等价于“到终点不计成本”。

建模技巧:


4. 午休的两种建模:优先用“更稳定”的方式

脚本同时实现了两种午休建模思路:

4.1 思路 A:客户点拆成 -am/-pm 的互斥二选一(Client Group)

如果客户“需要午休”,就把同一个客户拆成两个候选点:上午版本(截止 11:30)与下午版本(最早 13:00),用 client_group 把它们绑定成“二选一”。

细节:

这是一种非常标准的 VRPTW 技巧:把“需要午休”的复杂逻辑下沉为“时间窗选择”。

4.2 思路 B(脚本主线):两阶段求解,把午休当成“路线内事件”

脚本的 solve_two_stage_vrp() 采用更工程化的路线级处理:

  1. 先求 Stage 1(正常求解)。
  2. 根据每条 route 的 schedule,判断是否跨越午休窗口 12:00-13:00。
  3. 若跨越,则选择一个 break 点,把 route 切成 head/tail。
  4. 以 break 点为“新车辆起点”,构造 Stage 2 的自定义车辆(每条被切的 route 一辆)。
  5. Stage 2 只服务 tail 中的客户。
  6. 最后把 head + Lunch 节点 + tail 拼回一条“合并后的路线”,并导出。

对应实现:

为什么两阶段会更稳定:


5. 自定义车辆:把“从某个客户继续跑”变成标准 VRP

两阶段的关键是 Stage 2 的车辆不是从 VirtualDepot 出发,而是从 break 点出发。

5.1 给每辆 Stage2 车创建一个专用 start depot

对每条被切分的路线,脚本创建一个 depot:name="Start-<vehicle_name>",并把 tw_early 设置为“break 结束 + 30min”。

5.2 把已消耗的时间/里程扣掉(shift_duration / max_distance)

这是非常实用的技巧:Stage 2 的车辆不是“全新上班”,它在 Stage 1 已经跑了一段。所以 Stage 2 车辆类型要扣掉已消耗资源:

这样 Stage 2 的可行性就能自然反映“午休前已经跑太久/太远”的现实。

5.3 自定义 start depot 的边:用 custom_start_locs 做真实点映射

新加的 start depot 名字是 Start-...,不在距离矩阵里。脚本用 custom_start_locs 把它映射回真实 start_loc_name(再做 base name),然后查 dist_map

如果找不到边,会插入一条超大距离 1e7 的惩罚边。

工程含义:


6. 目标函数与约束的取舍:bike 不一定要“最短路”

脚本对 bike 的一个显著策略是:

这等价于:bike 阶段更关注“在可行的里程上限内把点覆盖掉”,而不是精细优化距离。

建模技巧:


7. 接驳(bike -> car)联动:用“最后点到达时间”更新 car 的最早服务时间

脚本的主程序(__main__)展示了一个完整联动流程:

  1. 先跑 bike 两阶段求解,得到合并后的路线 MergedSolution
  2. 从每条 bike 路线里,把“除最后一个客户外的点”标为 merge points(后续 car 不再访问);把“最后一个客户”作为 visit point(接驳点)。
  3. 生成 car 输入:删除 merge points,并把接驳点的 最早可开始提货时间 更新为 bike 到达时间。

对应实现:

建模技巧:


8. 结果导出:把“路线”结构化成可分析的表

export_merged_results() 会把合并后的 routes_data 展平为一个 Excel,字段包含:

关键实现:

这使得后续做“每条线路里程汇总 / 每条线路停靠点明细 / 回场距离”都不需要再解析求解器对象。


9. 你可以复用的“建模模板”

如果把 data/main.py 抽象成可复用模板,其结构大致是:

  1. 预处理:用距离矩阵过滤点(solve_vrp_base() 开头)。
  2. 单次 VRPTW 建模:
    • depot:s339(可选 DummyEnd
    • vehicles:VirtualDepot 或 custom start depots
    • clients:时间窗推导 + service duration 调整 +(可选)client group
    • edges:普通边 + VirtualDepot 边 + custom start depot 边
  3. 复杂业务拆分:两阶段(午休)与多阶段(bike->car 接驳)。
  4. 导出:把求解结果结构化为“停靠点表”。