红绿灯背后的算法:从 Webster 配时到 Max-Pressure 与强化学习

每天堵在路口的时候,你有没有想过:这个红灯为什么是 60 秒而不是 45 秒?为什么有的路一路绿灯,有的路每个口都停?

交通信号控制是一个被严重低估的算法领域------它同时涉及排队论整数规划图着色排队网络稳定性理论多智能体强化学习,而且每一个理论结论都直接对应马路上能看见的东西。本文从单点配时讲到路网自适应控制,公式给推导,算法给可运行代码。


目录


一、把路口抽象成一个数学问题

在写任何代码之前,先统一术语。交通工程里这几个词是精确定义的,混用会导致后面公式全错。

术语 英文 含义
流向 / 转向 movement 一个具体的通行方向,如"东进口左转"
相位 phase 一组互不冲突、同时放行的流向集合
周期 cycle, C C C 所有相位轮转一遍的总时长(秒)
绿信比 split, λ \lambda λ 某相位有效绿灯时间占周期的比例 λ = g / C \lambda = g/C λ=g/C
相位差 offset 相邻路口周期起点的时间偏移,绿波的关键
饱和流率 saturation flow, s s s 该车道排队车辆连续通过的最大速率,典型值 1800 veh/h/lane
损失时间 lost time, l l l 启动损失 + 黄灯末端不可用时间,单相位典型 3~4 s

有效绿灯 vs 显示绿灯

这是新手最容易搞混的一对概念。灯牌上显示的绿灯时长 G G G 并不等于真正能通车的时长。

绿灯刚亮时,前几辆车要反应、起步、加速,前 2~3 秒的通行效率远低于饱和流率,这部分叫启动损失时间 l 1 l_1 l1;黄灯亮起后还有一部分车会继续通过,这部分是"赚"回来的通行时间。于是定义:

g = G + A − l g = G + A - l g=G+A−l

其中 g g g 是有效绿灯时间 , A A A 是黄灯时长, l = l 1 + l 2 l = l_1 + l_2 l=l1+l2 是总损失时间。后面所有排队论公式里的绿灯,指的都是 g g g,不是 G G G。

流量比:衡量一个流向"有多挤"

y = q s y = \frac{q}{s} y=sq

q q q 是实际到达流率(veh/h), s s s 是饱和流率。 y y y 的物理意义是:这个流向至少需要占用多大比例的周期时间

一个路口能不能撑住,看的是各相位关键流向 (该相位内 y y y 最大的那个)的流量比之和:

Y = ∑ i = 1 n y i max ⁡ Y = \sum_{i=1}^{n} y_i^{\max} Y=i=1∑nyimax

Y ≥ 1 Y \geq 1 Y≥1 意味着无论怎么配时,路口都会持续拥堵------这时候再优化算法没有意义,需要的是渠化改造或者需求管理。这是整个信号控制领域最重要的一条边界:信号优化只能榨干现有通行能力,不能凭空创造通行能力。


二、相位设计:一个图着色问题

在算周期之前,得先决定"哪些流向可以一起放行"。这一步本质上是个组合优化问题。

冲突图建模

把每个流向抽象成图 G = ( V , E ) G=(V,E) G=(V,E) 的一个顶点,如果两个流向的行驶轨迹在路口内部有交叉(会撞车),就在它们之间连一条边。那么:

  • 一个相位 = 冲突图上的一个独立集(集合内任意两点无边相连)
  • 相位方案 = 用最少的独立集覆盖所有顶点

这正是经典的最小团覆盖 / 图着色问题,NP-hard。
#mermaid-svg-n2JGDSbKWreG5rpC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-n2JGDSbKWreG5rpC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-n2JGDSbKWreG5rpC .error-icon{fill:#552222;}#mermaid-svg-n2JGDSbKWreG5rpC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-n2JGDSbKWreG5rpC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-n2JGDSbKWreG5rpC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-n2JGDSbKWreG5rpC .marker.cross{stroke:#333333;}#mermaid-svg-n2JGDSbKWreG5rpC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-n2JGDSbKWreG5rpC p{margin:0;}#mermaid-svg-n2JGDSbKWreG5rpC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-n2JGDSbKWreG5rpC .cluster-label text{fill:#333;}#mermaid-svg-n2JGDSbKWreG5rpC .cluster-label span{color:#333;}#mermaid-svg-n2JGDSbKWreG5rpC .cluster-label span p{background-color:transparent;}#mermaid-svg-n2JGDSbKWreG5rpC .label text,#mermaid-svg-n2JGDSbKWreG5rpC span{fill:#333;color:#333;}#mermaid-svg-n2JGDSbKWreG5rpC .node rect,#mermaid-svg-n2JGDSbKWreG5rpC .node circle,#mermaid-svg-n2JGDSbKWreG5rpC .node ellipse,#mermaid-svg-n2JGDSbKWreG5rpC .node polygon,#mermaid-svg-n2JGDSbKWreG5rpC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-n2JGDSbKWreG5rpC .rough-node .label text,#mermaid-svg-n2JGDSbKWreG5rpC .node .label text,#mermaid-svg-n2JGDSbKWreG5rpC .image-shape .label,#mermaid-svg-n2JGDSbKWreG5rpC .icon-shape .label{text-anchor:middle;}#mermaid-svg-n2JGDSbKWreG5rpC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-n2JGDSbKWreG5rpC .rough-node .label,#mermaid-svg-n2JGDSbKWreG5rpC .node .label,#mermaid-svg-n2JGDSbKWreG5rpC .image-shape .label,#mermaid-svg-n2JGDSbKWreG5rpC .icon-shape .label{text-align:center;}#mermaid-svg-n2JGDSbKWreG5rpC .node.clickable{cursor:pointer;}#mermaid-svg-n2JGDSbKWreG5rpC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-n2JGDSbKWreG5rpC .arrowheadPath{fill:#333333;}#mermaid-svg-n2JGDSbKWreG5rpC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-n2JGDSbKWreG5rpC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-n2JGDSbKWreG5rpC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-n2JGDSbKWreG5rpC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-n2JGDSbKWreG5rpC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-n2JGDSbKWreG5rpC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-n2JGDSbKWreG5rpC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-n2JGDSbKWreG5rpC .cluster text{fill:#333;}#mermaid-svg-n2JGDSbKWreG5rpC .cluster span{color:#333;}#mermaid-svg-n2JGDSbKWreG5rpC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-n2JGDSbKWreG5rpC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-n2JGDSbKWreG5rpC rect.text{fill:none;stroke-width:0;}#mermaid-svg-n2JGDSbKWreG5rpC .icon-shape,#mermaid-svg-n2JGDSbKWreG5rpC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-n2JGDSbKWreG5rpC .icon-shape p,#mermaid-svg-n2JGDSbKWreG5rpC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-n2JGDSbKWreG5rpC .icon-shape .label rect,#mermaid-svg-n2JGDSbKWreG5rpC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-n2JGDSbKWreG5rpC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-n2JGDSbKWreG5rpC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-n2JGDSbKWreG5rpC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 冲突图示意
南北直行
东西直行
东左转
西左转
北左转
南左转
南直行

实际工程中不会真去解 NP-hard 问题,因为有额外的顺序约束 :相位不能任意跳转,必须遵守 NEMA 的环栅结构(ring-barrier structure)。标准四相位方案(对称放行)如下:

相位 放行流向 说明
Φ1 南北直行 + 右转
Φ2 南北左转 保护左转
Φ3 东西直行 + 右转
Φ4 东西左转

一个实用判断:要不要保护左转?

保护左转(专门给左转一个相位)会增加一次损失时间,代价是通行能力下降;但许可左转(跟直行一起放,找空档穿越)在对向流量大时会导致左转车排长队甚至溢出。经验阈值:

q 左 × q 对向直行 > 50000 ( veh/h ) 2 ⇒ 建议保护左转 q_{\text{左}} \times q_{\text{对向直行}} > 50000 \ (\text{veh/h})^2 \quad \Rightarrow \quad \text{建议保护左转} q左×q对向直行>50000 (veh/h)2⇒建议保护左转

这个"交叉乘积"判据在《城市道路交叉口设计规程》和 HCM 里都有类似形式,实际工程中还要叠加事故率数据。


三、单点定时控制:Webster 配时法

1958 年 F.V. Webster 在 TRL 报告里给出的方法,六十多年过去了,全世界绝大多数固定配时路口用的还是它。

3.1 最佳周期公式

Webster 从延误最小化出发,推导出使总延误最小的周期长度:

C 0 = 1.5 L + 5 1 − Y \boxed{C_0 = \frac{1.5L + 5}{1 - Y}} C0=1−Y1.5L+5

  • L = ∑ i l i L = \sum_i l_i L=∑ili:整个周期的总损失时间
  • Y Y Y:关键流量比之和

直观理解这个公式 :分子 1.5 L + 5 1.5L+5 1.5L+5 说明周期不能太短------损失时间是每周期固定支出的"税",周期越短,单位时间内交的税越多。分母 1 − Y 1-Y 1−Y 说明流量越大周期越长------但注意 Y → 1 Y \to 1 Y→1 时 C 0 → ∞ C_0 \to \infty C0→∞,所以工程上必须强制截断在 40~180 秒之间。周期超过 150 秒后,延误收益极其微弱,但驾驶员的等待焦虑和闯红灯率会显著上升。

3.2 绿灯时间分配

总有效绿灯时间为 C − L C - L C−L,按各相位流量比正比分配

g i = ( C − L ) ⋅ y i Y g_i = (C - L) \cdot \frac{y_i}{Y} gi=(C−L)⋅Yyi

这个分配方式的合理性在于:它使得所有相位的饱和度 x i = y i / λ i x_i = y_i / \lambda_i xi=yi/λi 相等,即"雨露均沾",没有哪个方向明显比别的方向更堵。

3.3 完整实现

python 复制代码
from dataclasses import dataclass
from typing import List, Dict


@dataclass
class Phase:
    name: str
    flow: float            # 关键流向流量 veh/h
    sat_flow: float        # 关键流向饱和流率 veh/h
    lost_time: float = 4.0 # 该相位损失时间 s


def webster_timing(
    phases: List[Phase],
    yellow: float = 3.0,
    all_red: float = 1.0,
    min_green: float = 10.0,
    cycle_bounds: tuple = (40.0, 180.0),
) -> Dict:
    """Webster 单点定时配时"""
    # 1) 流量比
    y = [p.flow / p.sat_flow for p in phases]
    Y = sum(y)
    if Y >= 0.95:
        raise ValueError(
            f"Y = {Y:.3f} >= 0.95,路口已接近或超过通行能力上限。"
            f"信号优化无法解决,需渠化改造或需求管理。"
        )

    # 2) 总损失时间
    L = sum(p.lost_time for p in phases)

    # 3) 最佳周期,取整到 5 秒并夹逼到工程范围
    C0 = (1.5 * L + 5.0) / (1.0 - Y)
    C = max(cycle_bounds[0], min(cycle_bounds[1], round(C0 / 5.0) * 5.0))

    # 4) 按流量比分配有效绿灯
    total_eff_green = C - L
    g_eff = [total_eff_green * yi / Y for yi in y]

    # 5) 有效绿灯 -> 显示绿灯:G = g + l - A
    G = [max(min_green, gi + p.lost_time - yellow)
         for gi, p in zip(g_eff, phases)]

    # 6) 若 min_green 触发导致超周期,按超出部分等比压缩未触底的相位
    inter_green = (yellow + all_red) * len(phases)
    overflow = sum(G) + inter_green - C
    if overflow > 1e-6:
        adjustable = [i for i, gi in enumerate(G) if gi > min_green + 1e-6]
        slack = sum(G[i] - min_green for i in adjustable)
        if slack > 0:
            for i in adjustable:
                G[i] -= overflow * (G[i] - min_green) / slack

    result = {
        "cycle": round(C, 1),
        "C0_theoretical": round(C0, 1),
        "Y": round(Y, 3),
        "total_lost_time": L,
        "phases": [],
    }
    for p, yi, gi, Gi in zip(phases, y, g_eff, G):
        lam = gi / C
        x = yi / lam if lam > 0 else float("inf")   # 饱和度
        result["phases"].append({
            "name": p.name,
            "flow_ratio_y": round(yi, 3),
            "green_display_G": round(Gi, 1),
            "green_effective_g": round(gi, 1),
            "split_lambda": round(lam, 3),
            "saturation_x": round(x, 3),
        })
    return result


if __name__ == "__main__":
    plan = webster_timing([
        Phase("南北直行", flow=1100, sat_flow=3400),
        Phase("南北左转", flow=280,  sat_flow=1650),
        Phase("东西直行", flow=900,  sat_flow=3400),
        Phase("东西左转", flow=220,  sat_flow=1650),
    ])
    print(f"周期 = {plan['cycle']} s   (理论最优 {plan['C0_theoretical']} s)")
    print(f"Y = {plan['Y']}")
    for ph in plan["phases"]:
        print(f"  {ph['name']:8s} 绿灯 {ph['green_display_G']:5.1f}s  "
              f"绿信比 {ph['split_lambda']:.3f}  饱和度 {ph['saturation_x']:.2f}")

运行结果:

复制代码
周期 = 75.0 s   (理论最优 73.6 s)
Y = 0.617
  南北直行     绿灯  24.4s  绿信比 0.339  饱和度 0.95
  南北左转     绿灯   9.4s  绿信比 0.139  饱和度 1.22
  东西直行     绿灯  20.3s  绿信比 0.284  饱和度 0.93
  东西左转     绿灯   8.0s  绿信比 0.120  饱和度 1.11

注意左转相位的饱和度 x > 1 x > 1 x>1,说明左转车道会持续积压。这在真实路口很常见,解决办法是增设左转专用车道(提高 s s s)或者用搭接相位(lead-lag,让一侧左转提前放行)。

3.4 延误估计:Webster 延误公式

有了配时方案,怎么知道它好不好?Webster 给出了每车平均延误的估计:

d = C ( 1 − λ ) 2 2 ( 1 − λ x ) ⏟ 均匀延误 + x 2 2 q ( 1 − x ) ⏟ 随机延误 − 0.65 ( C q 2 ) 1 / 3 x ( 2 + 5 λ ) ⏟ 修正项 d = \underbrace{\frac{C(1-\lambda)^2}{2(1-\lambda x)}}{\text{均匀延误}} + \underbrace{\frac{x^2}{2q(1-x)}}{\text{随机延误}} - \underbrace{0.65\left(\frac{C}{q^2}\right)^{1/3} x^{(2+5\lambda)}}_{\text{修正项}} d=均匀延误 2(1−λx)C(1−λ)2+随机延误 2q(1−x)x2−修正项 0.65(q2C)1/3x(2+5λ)

三项各有含义:

  1. 均匀延误:假设车辆均匀到达时,因为红灯必然产生的等待。这是确定性的、不可消除的部分。
  2. 随机延误 :车辆实际到达是随机的(近似泊松流),偶尔一个周期来车特别多就会有溢出排队。注意 x → 1 x \to 1 x→1 时这项发散,这就是为什么饱和度超过 0.9 之后延误会爆炸式增长
  3. 修正项:Webster 通过仿真拟合的经验修正,大约是前两项之和的 5%~15%。
python 复制代码
def webster_delay(q_vph: float, s_vph: float, C: float, g: float) -> float:
    """Webster 平均延误 (s/veh)。注意公式内部单位为 veh/s"""
    q = q_vph / 3600.0
    lam = g / C
    capacity = (s_vph / 3600.0) * lam
    x = q / capacity
    if x >= 1.0:
        return float("inf")   # 过饱和,稳态延误不存在

    d1 = C * (1 - lam) ** 2 / (2 * (1 - lam * x))
    d2 = x ** 2 / (2 * q * (1 - x))
    d3 = 0.65 * (C / q ** 2) ** (1 / 3) * x ** (2 + 5 * lam)
    return d1 + d2 - d3

你可以用它做一件很有意思的事:扫描周期长度画延误曲线 ,会发现曲线在 Webster 最优周期附近相当平坦------这意味着周期取 70 秒还是 80 秒,延误差别很小。这个"平底锅"特性是 Webster 法能用六十年的根本原因:它对参数误差不敏感,鲁棒性极好。


四、干线协调:绿波带是怎么算出来的

单点最优 ≠ 全局最优。一条主干道上每个路口各自算 Webster,结果很可能是每个口都停一次。协调控制解决的就是这个问题。

4.1 单向绿波:一个除法

前提:协调路口必须使用相同周期(或整数倍周期,称为"双周期")。这是协调的硬约束------周期不同,相位关系会持续漂移,绿波无法维持。

设相邻路口间距 d d d(米),设计车速 v v v(m/s),则理想相位差:

offset i = ( ∑ k ≤ i d k v )   m o d   C \text{offset}{i} = \left(\sum{k \le i} \frac{d_k}{v}\right) \bmod C offseti=(k≤i∑vdk)modC

python 复制代码
def green_wave_offsets(distances_m, speed_kmh, cycle_s):
    """单向绿波相位差计算
    distances_m: 相邻路口间距列表,长度 = 路口数 - 1
    返回: 各路口相对首个路口的相位差 (s)
    """
    v = speed_kmh / 3.6
    offsets, acc = [0.0], 0.0
    for d in distances_m:
        acc += d / v
        offsets.append(round(acc % cycle_s, 1))
    return offsets


print(green_wave_offsets([420, 380, 550, 300], speed_kmh=50, cycle_s=90))
# [0.0, 30.2, 57.6, 17.2, 38.8]

4.2 双向绿波:为什么这么难

真正的难点在于双向。上行绿波和下行绿波的相位差要求方向相反,通常无法同时满足。

数学上,设路口 i i i 到 i + 1 i+1 i+1 的行程时间为 t i t_i ti,双向绿波同时成立的条件是:

2 t i = m i ⋅ C , m i ∈ Z + 2t_i = m_i \cdot C, \quad m_i \in \mathbb{Z}^{+} 2ti=mi⋅C,mi∈Z+

也就是说,路口间距必须恰好等于 v C 2 \frac{vC}{2} 2vC 的整数倍 。代入典型值: v = 50 v=50 v=50 km/h, C = 90 C=90 C=90 s,得到理想间距 ≈ 625 \approx 625 ≈625 m 或其整数倍。

这解释了一个现象:新城区宽马路容易做绿波,老城区密路网做不了------间距不匹配是物理约束,不是算法问题。当间距不理想时,只能在两个方向之间做权衡,这就是 MAXBAND 模型要解决的事。

4.3 MAXBAND:带宽最大化的 MILP

Little 等人 1981 年提出的 MAXBAND,把双向绿波建模为混合整数线性规划:

max ⁡ b + b ˉ s.t. w i + b ≤ g i (上行带落在绿灯内) w ˉ i + b ˉ ≤ g i (下行带落在绿灯内) ( w i + w ˉ i ) − ( w i + 1 + w ˉ i + 1 ) + ( t i + t ˉ i ) = m i ∈ Z b / b ˉ = k (双向带宽比,按流量设定) w i , w ˉ i , b , b ˉ ≥ 0 \begin{aligned} \max \quad & b + \bar{b} \\ \text{s.t.} \quad & w_i + b \le g_i \quad \text{(上行带落在绿灯内)} \\ & \bar{w}i + \bar{b} \le g_i \quad \text{(下行带落在绿灯内)} \\ & (w_i + \bar w_i) - (w{i+1} + \bar w_{i+1}) + (t_i + \bar t_i) = m_i \in \mathbb{Z} \\ & b / \bar b = k \quad \text{(双向带宽比,按流量设定)} \\ & w_i, \bar w_i, b, \bar b \ge 0 \end{aligned} maxs.t.b+bˉwi+b≤gi(上行带落在绿灯内)wˉi+bˉ≤gi(下行带落在绿灯内)(wi+wˉi)−(wi+1+wˉi+1)+(ti+tˉi)=mi∈Zb/bˉ=k(双向带宽比,按流量设定)wi,wˉi,b,bˉ≥0

其中 b , b ˉ b, \bar b b,bˉ 是上下行带宽, w i w_i wi 是绿波带前沿到绿灯起点的间隙, m i m_i mi 是整数环路约束------正是这个整数变量让问题变成了 NP-hard 的 MILP。

工程上有个更简单的替代品叫时距图人工调优:把时空图画出来,拖动各路口绿灯条,肉眼找最大带宽。听起来原始,但对于 5~8 个路口的干线,老工程师调出来的结果常常不比求解器差,而且可解释性强得多。

关键提醒 :绿波只在非饱和 条件下有意义。一旦某个路口排队溢出到上游,绿波带里跑的是排队车辆的启动波,不是自由流,此时整条线的协调方案会瞬间失效。高峰期最该做的不是绿波,是排队管理和溢出保护。


五、感应控制:让路口"看见"车流

定时控制的根本缺陷是开环------它不知道路上到底有没有车。凌晨三点空无一人的路口照样让你等 60 秒红灯,就是这个原因。

5.1 基本感应逻辑

在停车线上游埋设线圈或架设视频检测器,逻辑如下:

复制代码
当前相位绿灯开始
├── 保证最小绿灯时间 G_min(行人过街 + 安全约束)
└── 进入延长阶段:
    ├── 每检测到一辆车 → 绿灯延长 unit_extension(典型 2~3 s)
    ├── 若 unit_extension 内无车到达 → 判定"断流",切换相位
    └── 若累计时长达到 G_max → 强制切换(防止某方向饿死)
python 复制代码
class ActuatedController:
    def __init__(self, g_min=10.0, g_max=60.0, unit_ext=2.5,
                 yellow=3.0, all_red=1.0, n_phases=4):
        self.g_min, self.g_max, self.unit_ext = g_min, g_max, unit_ext
        self.yellow, self.all_red = yellow, all_red
        self.n_phases = n_phases
        self.phase = 0
        self.elapsed = 0.0
        self.gap = 0.0          # 距上次检测到车的时间

    def step(self, dt: float, vehicle_detected: bool):
        self.elapsed += dt
        self.gap = 0.0 if vehicle_detected else self.gap + dt

        if self.elapsed < self.g_min:
            return self.phase                      # 最小绿保护期

        if self.elapsed >= self.g_max:             # 强制切换
            return self._switch()

        if self.gap >= self.unit_ext:              # 断流,让给下一相位
            return self._switch()

        return self.phase                          # 继续延长

    def _switch(self):
        self.phase = (self.phase + 1) % self.n_phases
        self.elapsed = 0.0
        self.gap = 0.0
        return self.phase

5.2 SCOOT 与 SCATS:工业界的两条路线

维度 SCOOT (英国 TRL) SCATS (澳大利亚 RMS)
核心思想 在线仿真"车队曲线",预测未来延误 基于历史方案库的动态选择
调整方式 周期/绿信比/相位差小步渐进(每次 ±4 s) 按饱和度在预设方案间切换
检测器位置 上游进口道(预测型) 停车线处(反馈型)
优化目标 最小化延误 + 停车次数 最大化通行能力、均衡饱和度
适用场景 流量平稳、路网规则 流量波动大、需快速响应

两者都属于方案生成式 (SCOOT)或方案选择式 (SCATS)的第二代系统。它们的共同局限是:调整幅度受限于稳定性要求,面对突发事件(事故、活动散场)响应偏慢


六、Max-Pressure:有理论保证的自适应控制

前面的方法都靠经验参数。有没有一个算法,不需要知道到达率,还能证明它是最优的?有------Max-Pressure(最大压力控制),源自 Tassiulas & Ephremides 1992 年在无线网络调度中提出的 BackPressure 算法,2013 年由 Varaiya 引入交通控制。

6.1 压力的定义

对每个流向 m m m(从上游链路 u u u 到下游链路集合 { d } \{d\} {d}),定义其压力

w m = x u − ∑ d r u d ⋅ x d w_m = x_u - \sum_{d} r_{ud} \cdot x_d wm=xu−d∑rud⋅xd

  • x u x_u xu:上游链路排队车辆数
  • x d x_d xd:下游链路排队车辆数
  • r u d r_{ud} rud:转向比例(从 u u u 去往 d d d 的车辆占比)

相位 ϕ \phi ϕ 的压力是其包含的所有流向压力的饱和流率加权和

P ϕ = ∑ m ∈ ϕ s m ⋅ max ⁡ ( w m , 0 ) P_\phi = \sum_{m \in \phi} s_m \cdot \max(w_m,\ 0) Pϕ=m∈ϕ∑sm⋅max(wm, 0)

控制律:每个决策时刻,选择压力最大的相位放行。

ϕ ⋆ = arg ⁡ max ⁡ ϕ ∈ Φ P ϕ \phi^\star = \arg\max_{\phi \in \Phi} P_\phi ϕ⋆=argϕ∈ΦmaxPϕ

6.2 为什么它是对的

这个算法的美妙之处在于它的直觉极其简单,但有严格的理论保证:

定理(Varaiya, 2013) :若需求向量位于路网的可行容量域内部,则 Max-Pressure 控制使排队长度过程正常返(positive recurrent) ,即队列不会无界增长。且该算法在所有仅使用队列信息的控制策略中,稳定域最大

关键点在于:它不需要知道到达率 q q q,只需要实时队列观测。这意味着它天然适应流量变化,不需要重新标定------这在工程上是巨大优势,因为交通流量的标定数据往往几年才更新一次。

直觉上, w m = x u − ∑ r x d w_m = x_u - \sum r x_d wm=xu−∑rxd 这个差值的作用是:上游堵了就放行,但如果下游更堵,就别放了 ------放过去也是堵在下一个路口,还会造成溢出,反而降低整体吞吐。这自然实现了溢出保护,而 Webster 和绿波都做不到这一点。

6.3 实现

python 复制代码
from dataclasses import dataclass, field
from typing import List, Dict, Tuple


@dataclass
class Movement:
    """一个流向:从上游链路进入,按转向比流向多个下游链路"""
    up: str
    downs: Dict[str, float]      # {下游链路: 转向比}
    sat_flow: float = 1800.0     # veh/h


@dataclass
class MaxPressureController:
    phases: List[List[Movement]]
    min_green: float = 8.0
    yellow: float = 3.0
    cur_phase: int = 0
    elapsed: float = 0.0

    def pressure(self, phase: List[Movement], queues: Dict[str, float]) -> float:
        total = 0.0
        for m in phase:
            w = queues.get(m.up, 0.0) - sum(
                r * queues.get(d, 0.0) for d, r in m.downs.items()
            )
            total += m.sat_flow * max(w, 0.0)
        return total

    def step(self, dt: float, queues: Dict[str, float]) -> Tuple[int, bool]:
        """返回 (当前相位索引, 是否发生切换)"""
        self.elapsed += dt
        if self.elapsed < self.min_green:
            return self.cur_phase, False

        pressures = [self.pressure(p, queues) for p in self.phases]
        best = max(range(len(pressures)), key=lambda i: pressures[i])

        # 迟滞:新相位压力需显著更高才切换,避免相位抖动
        if best != self.cur_phase and pressures[best] > pressures[self.cur_phase] * 1.05:
            self.cur_phase = best
            self.elapsed = 0.0
            return self.cur_phase, True
        return self.cur_phase, False


# 示例
NS = [Movement("N_in", {"S_out": 0.8, "W_out": 0.2}),
      Movement("S_in", {"N_out": 0.8, "E_out": 0.2})]
EW = [Movement("E_in", {"W_out": 0.85, "N_out": 0.15}),
      Movement("W_in", {"E_out": 0.85, "S_out": 0.15})]

ctrl = MaxPressureController(phases=[NS, EW])
queues = {"N_in": 18, "S_in": 15, "E_in": 4, "W_in": 6,
          "N_out": 2, "S_out": 25, "E_out": 3, "W_out": 2}

print("南北压力:", ctrl.pressure(NS, queues))
print("东西压力:", ctrl.pressure(EW, queues))

注意示例中 S_out 排队已达 25 辆------南北向虽然自己排了 18+15 辆,但下游更堵,压力被大幅削减,算法会倾向于先放东西向。这正是溢出保护在起作用。

6.4 实际部署的两个修正

  1. 加迟滞或最小绿灯 。纯 Max-Pressure 允许每个决策周期切换相位,实际会导致相位频繁抖动,驾驶员体验极差且损失时间剧增。代码中 min_green 和 5% 的迟滞阈值就是为此。
  2. 队列估计是难点 。算法要求实时队列长度,但线圈只能测过车数和占有率。实践中需要用冲击波模型或卡尔曼滤波从检测器数据反推队列,这部分的误差往往比算法本身的差异更影响效果。

七、强化学习方案:能力边界在哪里

近几年 RL 做信号控制的论文非常多,这里不复述模型结构,只讲怎么设计什么时候不该用

7.1 MDP 建模

状态空间。常见设计从简到繁:

  • 各进口道排队长度 + 当前相位 one-hot + 当前相位已持续时长(最实用,维度低)
  • 离散交通状态编码 DTSE:把每条车道切成 net 格,标记有无车辆(信息全但维度高)
  • 压力向量(借鉴 Max-Pressure,被证明是很强的状态表示)

动作空间。两种范式:

  • 相位选择:直接输出下一个相位。灵活,但可能违反环栅约束,且相位跳变影响驾驶体验。
  • 保持/切换二元决策:只决定"当前相位继续还是进入下一个"。约束天然满足,工程更可接受,是我更推荐的做法。

奖励函数。这是全部工作里最容易出问题的地方:

r t = − ∑ i x i ( t ) (负排队总长,最常用) r_t = -\sum_i x_i^{(t)} \quad \text{(负排队总长,最常用)} rt=−i∑xi(t)(负排队总长,最常用)

r t = ∑ i ( x i ( t − 1 ) − x i ( t ) ) (排队变化量,稠密但易陷入局部最优) r_t = \sum_i \left(x_i^{(t-1)} - x_i^{(t)}\right) \quad \text{(排队变化量,稠密但易陷入局部最优)} rt=i∑(xi(t−1)−xi(t))(排队变化量,稠密但易陷入局部最优)

r t = − ∑ i d i ( t ) (累计延误,最贴近目标但稀疏) r_t = -\sum_i d_i^{(t)} \quad \text{(累计延误,最贴近目标但稀疏)} rt=−i∑di(t)(累计延误,最贴近目标但稀疏)

踩坑提醒 :用"排队变化量"做奖励会产生一个隐蔽的病态策略------智能体学会反复在两个相位间快速切换 ,因为切换瞬间总能让某个方向的队列下降,从而刷取正奖励,而真实通行效率因为损失时间暴增反而更差。一定要在奖励里加入切换惩罚项。

7.2 多路口:MARL 的信用分配困境

单路口 RL 打败 Webster 是很容易的(论文里 20%~30% 的延误下降很常见),但推广到路网就困难得多:

  • 非平稳性:每个智能体的环境包含其他智能体,而它们也在学习,环境分布持续变化,收敛性无保证。
  • 信用分配:全局奖励下降了,是哪个路口的功劳?
  • 通信开销:完全集中式训练在 50 个路口以上就不可行。

主流缓解手段是 CTDE(集中训练分散执行)、参数共享(所有路口共用一套网络权重)、以及用图神经网络编码路网拓扑。

7.3 我的实用判断

场景 推荐方案
流量稳定的孤立路口 Webster 定时,够用且免维护
流量波动大的孤立路口 感应控制
规则干线,需要连续通行 定时协调 + MAXBAND
密集路网,易溢出 Max-Pressure(性价比最高)
有高保真仿真环境 + 长期投入 RL,但先用 Max-Pressure 做 baseline

一个反直觉的事实 :绝大多数 RL 信号控制论文对比的 baseline 是固定配时或简单感应控制,如果拿 Max-Pressure 做对比,优势会大幅缩水甚至消失。做研究的话,请务必把 Max-Pressure 加入 baseline,否则结论说服力有限。


八、怎么评估一套配时方案

只看单一指标一定会被误导,标准做法是同时看这几个:

指标 定义 说明
平均延误 d ˉ = 1 N ∑ i d i \bar d = \frac{1}{N}\sum_i d_i dˉ=N1∑idi 最常用,但会掩盖长尾
90 分位延误 d 90 d_{90} d90 衡量公平性,防止某方向被饿死
平均停车次数 每车经过路口停车次数 直接关联油耗与排放
排队长度 周期最大排队 L max ⁡ L_{\max} Lmax 判断是否溢出,安全相关
通行量 单位时间通过车辆数 过饱和场景下的第一指标
溢出次数 排队超过路段长度的次数 过饱和路网最关键的指标

关于工具选择:

  • SUMO:开源,微观仿真,TraCI 接口成熟,路网可从 OSM 导入。做研究首选。
  • CityFlow:为 RL 优化,仿真速度比 SUMO 快一个数量级,但模型精度略低。跑大量训练回合时用它。
  • VISSIM:商业软件,标定精细,国内交通设计院出方案基本用它,评审认可度高。

必须注意 :仿真结论迁移到真实世界的最大障碍不是算法,是参数标定。饱和流率、驾驶员反应时间、期望车头时距这些参数如果没有用本地实测数据标定,仿真里 30% 的提升到现场可能只剩 5%,甚至为负。


九、工程落地的十个坑

这一节是纯算法文章不会讲的,但恰恰是决定方案能不能上线的关键。

1. 最小绿灯时间由行人决定,不是由车决定。 行人过街所需时间:

G min ⁡ = t 反应 + W v 行人 G_{\min} = t_{\text{反应}} + \frac{W}{v_{\text{行人}}} Gmin=t反应+v行人W

W W W 是过街宽度, v 行人 v_{\text{行人}} v行人 通常取 1.2 m/s(老年人多的区域取 1.0 m/s)。30 米宽的路口,最小绿灯至少 32 秒------任何算法都不能突破这个下限。

2. 黄灯时长与困境区。 黄灯太短会形成"两难区"(dilemma zone):驾驶员既刹不住,也过不去。安全黄灯时长:

A = t r + v 2 a + 2 g G A = t_r + \frac{v}{2a + 2gG} A=tr+2a+2gGv

t r t_r tr 为反应时间(1.0 s), a a a 为舒适减速度(3.0 m/s²), G G G 为道路坡度。50 km/h 平路约需 3.4 s。

3. 全红清空时间不能省。 R = W + L 车 v R = \frac{W + L_{\text{车}}}{v} R=vW+L车, W W W 为路口宽度。大路口需要 2~3 秒全红,省掉它会直接提高事故率。

4. 相位不能任意跳跃。 必须遵守环栅结构,且不能反向跳转。RL 直接输出相位编号的方案在真实信号机上往往无法落地,因为控制器固件不支持。

5. 检测器会坏。 线圈的年故障率在 10%~20% 量级。所有自适应算法必须有降级策略:检测失效时自动回退到定时方案,而不是按"队列=0"处理(那会导致该方向永远得不到绿灯)。

6. 公交与应急优先。 优先请求会打断正常配时,需要设计补偿机制在后续周期还回被占用的绿灯时间,否则被占用方向会持续积压。

7. 过饱和场景要换目标函数。 饱和度超过 1 时,延误最小化没有意义(延误理论上是无穷大),此时目标应改为最大化通行量防止溢出------这是完全不同的优化问题。

8. 相位差调整要渐进。 直接跳变相位差会造成某个周期出现异常长的红灯。工程做法是每周期最多调整 ±3~5 秒,逐步过渡到目标值。

9. 上游溢出会让协调方案失效。 排队溢出到上游路口后,绿波带里跑的是启动波而非自由流,此时应立即切换到排队管理模式,而不是坚持执行协调方案。

10. 驾驶员是有记忆的。 配时方案频繁变化会破坏驾驶员的预期,导致抢黄灯、急刹增多。稳定性本身就是一种价值,这也是为什么很多城市宁可用略微次优的固定配时。


十、总结与延伸阅读

回到开头的问题:为什么红灯是 60 秒?

现在你知道,这个数字可能来自 C 0 = 1.5 L + 5 1 − Y C_0 = \frac{1.5L+5}{1-Y} C0=1−Y1.5L+5 的计算,可能是为了和相邻路口凑成绿波而取的公共周期,也可能是三十米宽路口的行人过街时间倒逼出来的下限。

整个领域的技术演进可以概括成一条主线:
#mermaid-svg-am0rYnVaTXO2qjGF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-am0rYnVaTXO2qjGF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-am0rYnVaTXO2qjGF .error-icon{fill:#552222;}#mermaid-svg-am0rYnVaTXO2qjGF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-am0rYnVaTXO2qjGF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-am0rYnVaTXO2qjGF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-am0rYnVaTXO2qjGF .marker.cross{stroke:#333333;}#mermaid-svg-am0rYnVaTXO2qjGF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-am0rYnVaTXO2qjGF p{margin:0;}#mermaid-svg-am0rYnVaTXO2qjGF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-am0rYnVaTXO2qjGF .cluster-label text{fill:#333;}#mermaid-svg-am0rYnVaTXO2qjGF .cluster-label span{color:#333;}#mermaid-svg-am0rYnVaTXO2qjGF .cluster-label span p{background-color:transparent;}#mermaid-svg-am0rYnVaTXO2qjGF .label text,#mermaid-svg-am0rYnVaTXO2qjGF span{fill:#333;color:#333;}#mermaid-svg-am0rYnVaTXO2qjGF .node rect,#mermaid-svg-am0rYnVaTXO2qjGF .node circle,#mermaid-svg-am0rYnVaTXO2qjGF .node ellipse,#mermaid-svg-am0rYnVaTXO2qjGF .node polygon,#mermaid-svg-am0rYnVaTXO2qjGF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-am0rYnVaTXO2qjGF .rough-node .label text,#mermaid-svg-am0rYnVaTXO2qjGF .node .label text,#mermaid-svg-am0rYnVaTXO2qjGF .image-shape .label,#mermaid-svg-am0rYnVaTXO2qjGF .icon-shape .label{text-anchor:middle;}#mermaid-svg-am0rYnVaTXO2qjGF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-am0rYnVaTXO2qjGF .rough-node .label,#mermaid-svg-am0rYnVaTXO2qjGF .node .label,#mermaid-svg-am0rYnVaTXO2qjGF .image-shape .label,#mermaid-svg-am0rYnVaTXO2qjGF .icon-shape .label{text-align:center;}#mermaid-svg-am0rYnVaTXO2qjGF .node.clickable{cursor:pointer;}#mermaid-svg-am0rYnVaTXO2qjGF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-am0rYnVaTXO2qjGF .arrowheadPath{fill:#333333;}#mermaid-svg-am0rYnVaTXO2qjGF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-am0rYnVaTXO2qjGF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-am0rYnVaTXO2qjGF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-am0rYnVaTXO2qjGF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-am0rYnVaTXO2qjGF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-am0rYnVaTXO2qjGF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-am0rYnVaTXO2qjGF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-am0rYnVaTXO2qjGF .cluster text{fill:#333;}#mermaid-svg-am0rYnVaTXO2qjGF .cluster span{color:#333;}#mermaid-svg-am0rYnVaTXO2qjGF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-am0rYnVaTXO2qjGF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-am0rYnVaTXO2qjGF rect.text{fill:none;stroke-width:0;}#mermaid-svg-am0rYnVaTXO2qjGF .icon-shape,#mermaid-svg-am0rYnVaTXO2qjGF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-am0rYnVaTXO2qjGF .icon-shape p,#mermaid-svg-am0rYnVaTXO2qjGF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-am0rYnVaTXO2qjGF .icon-shape .label rect,#mermaid-svg-am0rYnVaTXO2qjGF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-am0rYnVaTXO2qjGF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-am0rYnVaTXO2qjGF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-am0rYnVaTXO2qjGF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 定时控制

Webster 1958
协调控制

MAXBAND 1981
自适应系统

SCOOT / SCATS
理论最优

Max-Pressure 2013
学习型控制

MARL

但请记住三条不会过时的判断:

  1. 信号优化的上限是路口的物理通行能力。 Y ≥ 1 Y \ge 1 Y≥1 时,再好的算法也救不了,该做的是渠化改造。
  2. Max-Pressure 是当前性价比最高的自适应方案。 理论有保证、不需要标定到达率、天然带溢出保护、代码不到一百行。
  3. 约束比目标函数更重要。 最小绿灯、清空时间、检测器降级------这些不满足,方案根本上不了线。

参考文献

  • Webster, F.V. (1958). Traffic Signal Settings. Road Research Technical Paper No. 39, HMSO.
  • Little, J.D.C., Kelson, M.D., Gartner, N.H. (1981). MAXBAND: A Program for Setting Signals on Arteries and Triangular Networks. Transportation Research Record, 795.
  • Varaiya, P. (2013). Max pressure control of a network of signalized intersections. Transportation Research Part C, 36, 177--195.
  • Tassiulas, L., Ephremides, A. (1992). Stability properties of constrained queueing systems and scheduling policies for maximum throughput in multihop radio networks. IEEE Transactions on Automatic Control, 37(12).
  • Transportation Research Board. Highway Capacity Manual (HCM), 7th Edition.
  • Wei, H. et al. (2019). PressLight: Learning Max Pressure Control to Coordinate Traffic Signals in Arterial Network. KDD 2019.
  • 中华人民共和国住房和城乡建设部. 《城市道路交叉口设计规程》CJJ 152.

本文所有代码可直接运行,仿真部分建议配合 SUMO 或 CityFlow 使用。如果你在实际项目中遇到过其他坑,欢迎评论区交流。

相关推荐
木井巳1 小时前
【DFS解决floodfill算法】岛屿的最大面积
java·算法·leetcode·深度优先
June`3 小时前
常量内存和只读缓存
c++·人工智能·算法·cuda
一条大祥脚3 小时前
ABC468 扫描线|贡献法|二阶差分|线段树优化DP
数据结构·算法
happyprince3 小时前
篇2-bitsandbytes-具体观-算法与实现剖析
算法
普通攻击往后拉13 小时前
Leetcode 206. 反转链表
算法·leetcode·链表
@syh.13 小时前
【贪心】矩阵消除游戏
算法·游戏·矩阵
可编程芯片开发14 小时前
基于零极点配置的PID控制系统simulink建模与仿真
算法
徐小夕14 小时前
开源!我用SQLite + DuckDB打造了一款可视化AI问数平台
前端·算法·github