Research KB 登录

EX-001 Example

直接配点法算例(最小功双积分器):梯形配点 + 松弛变量把问题变成线性规划,N=10→160 网格下目标值 0.7958→0.76405 逼近解析 bang-off-bang 解的 0.763932,状态误差约按 O(h²) 下降,切换时刻收敛到 τ≈0.381966。

id
type example
instantiates CPT-020, CPT-021, CPT-024, CPT-027, CPT-028
generator examples/EX-001.py
generator-commit 5b0e038
params {x_target: 1.0, u_max: 1.0, T: 3.0, meshes: [10, 20, 40, 80, 160], solver: highs}
result-hash sha256:c15ef607198cf8885abc77bb7e663f110a348c4687ac1b3ecfdc06edba82fb38
fidelity checked
updated
x(t) 0 … 1 v(t) 0 … 1 u(t) -1.2 … 1.2 t(红:配点数值解;蓝虚线:解析 bang-bang 解)

问题

最小功双积分器(minimum-effort double integrator):在固定时间 \(T\) 内把静止在 \(x=0\) 的质点移动到 \(x=x_{\text{target}}=1\) 并停住,最小化控制消耗

\[\min_{u(\cdot)}\ \int_0^T |u(t)|\,\mathrm{d}t \quad\text{s.t.}\quad \dot x=v,\ \dot v=u,\quad x(0)=v(0)=0,\ x(T)=1,\ v(T)=0,\ |u|\le u_{\max}\]

\(u_{\max}=1\)\(T=3\)(大于最小时间 \(2\sqrt{x_{\text{target}}/u_{\max}}=2\))。该问题的解析最优控制是 bang-off-bang 三相位结构:加速 \(u=+u_{\max}\)\([0,\tau]\)、巡航 \(u=0\)\([\tau,T-\tau]\)、减速 \(u=-u_{\max}\)\([T-\tau,T]\),其中 \(\tau\)\(-u_{\max}\tau^2+u_{\max}T\tau=x_{\text{target}}\) 定出,即 \(\tau=(T-\sqrt{T^2-4})/2\approx0.381966\),最小功 \(2u_{\max}\tau\approx0.763932\)

转录与求解

梯形配点CPT-020)在 \(N\) 个等距区间上离散:状态与控制都取节点值,节点间满足

\[x_{k+1}=x_k+\frac h2(v_k+v_{k+1}),\qquad v_{k+1}=v_k+\frac h2(u_k+u_{k+1})\]

目标中的 \(|u|\) 不可微,引入松弛变量 \(\sigma_k\ge|u_k|\)CPT-027)并最小化梯形加权的 \(\sum_k w_k\sigma_k\)\(w_0=w_N=h/2\),其余 \(w_k=h\))。由于动力学与目标对 \((x_k,v_k,u_k,\sigma_k)\) 都是线性的,这个转录恰好是一个线性规划,用 scipy.optimize.linprog(method="highs") 求全局最优(CPT-022 的一般非线性规划在此退化为 LP)。决策变量每节点 4 个,\(N=160\) 时共 644 个变量。

结果

下表是各网格下的机器结果(权威数值在 examples/EX-001.jsonresult-hash 已锁定):

\(N\) \(h\) 目标值 目标误差 \(t_1\) \(t_2\) \(\lVert x\rVert_\infty\) 误差 \(\lVert v\rVert_\infty\) 误差 切换外 \(\lVert u\rVert_\infty\) 误差
10 0.300 0.795833333 \(3.19\times10^{-2}\) 0.418487 2.581513 \(1.44\times10^{-2}\) \(2.60\times10^{-2}\) 0
20 0.150 0.770238095 \(6.31\times10^{-3}\) 0.380426 2.619574 \(2.93\times10^{-3}\) \(3.15\times10^{-3}\) 0
40 0.075 0.766388889 \(2.46\times10^{-3}\) 0.388450 2.611550 \(1.29\times10^{-3}\) \(1.47\times10^{-2}\) 0
80 0.0375 0.764513889 \(5.82\times10^{-4}\) 0.385464 2.614536 \(3.16\times10^{-4}\) \(5.75\times10^{-3}\) 0
160 0.01875 0.764045139 \(1.13\times10^{-4}\) 0.383030 2.616970 \(6.26\times10^{-5}\) \(1.18\times10^{-3}\) 0

解析参考:目标值 0.763932023,切换时刻 \(t_1=0.381966011\)\(t_2=2.618033989\)

解读

三件事从这个算例里可以看到(而不是从文字上接受)。第一,配点转录确实再现了 bang-off-bang 结构(CPT-024):最细网格上控制只在 \(u=+1,0,-1\) 三个值附近取值,切换外的控制误差恒为 0——LP 把控制推到约束边界上;切换时刻随网格加密收敛到解析值 \(\tau\)。第二,状态误差随网格加密下降,\(\lVert x\rVert_\infty\) 误差从 \(1.44\times10^{-2}\) 降到 \(6.26\times10^{-5}\),每步约 4–5 倍,与 \(O(h^2)\) 一致;\(\lVert v\rVert_\infty\) 误差则非单调\(N=40\) 反而大于 \(N=20\)),因为它的峰值由切换点是否恰好落在节点上主导,而非由全局网格尺度主导——这本身是配点法误差分布的可见特征。第三,\(T=2\)(连续意义下的最小时间)处梯形配点的 LP 不可行:离散可达集比连续可达集略小(\(N=10\) 时最多到 0.98),所以本算例取 \(T=3\)。这是离散化误差的一个直接、可复现的体现,也说明 "配点约束等价于隐式 Runge–Kutta 格式"(CPT-028)不只是形式对应——离散格式的可达集与连续系统并不完全重合。

边界

本卡是投影对象(D28):它不承载断言,数值输出不构成证据——按 A2,Evidence 支持命题但不构成系统内证明;按 DA8,本系统的验证一律指文献级,不承诺重跑实验。算例的 epistemic 由 instantiates 指向的对象继承,可靠性轴用 fidelity:此处 checked,因为数值结果与解析解一致且差异已归因(网格尺度与切换点对齐)。正文的数字均为 examples/EX-001.json 的转述,权威在该文件与其生成器。

Provenance

  • 生成器:examples/EX-001.py(自包含:建模 → LP 求解 → 覆写 EX-001.jsonEX-001.svg;D28 同置契约)。
  • 运行:examples/.venv/bin/python examples/EX-001.py(numpy 2.2.6 + scipy 1.15.3,HiGHS 求解器)。
  • 确定性:同参数下 HiGHS 结果可复现;输出数值四舍五入到 9 位,result-hashEX-001.json 去尾空白后的 sha256。
  • 生成于 commit 5b0e038
生成器源码(examples/EX-001.py)
#!/usr/bin/env python3
"""EX-001:直接配点法算例 —— 最小功双积分器(min-effort double integrator)。

问题(与 Kelly 2017 教程同型的最小功问题,PPR-004):

    min  ∫_0^T |u(t)| dt
    s.t. ẋ = v,  v̇ = u
         x(0)=v(0)=0,  x(T)=1,  v(T)=0,  |u(t)| ≤ u_max

转录(CPT-020 配点法):N 个等距区间,梯形配点约束

    x_{k+1} = x_k + h(v_k + v_{k+1})/2
    v_{k+1} = v_k + h(u_k + u_{k+1})/2

目标函数对 |u| 不可微,引入松弛变量 σ_k ≥ |u_k|(CPT-027),
用梯形权重离散目标:min Σ w_k σ_k(w_0=w_N=h/2,其余 h)。
动力学与目标对 (x, v, u, σ) 线性 → 该转录恰为线性规划,
用 scipy.optimize.linprog(method="highs") 精确求解(全局最优)。

解析解(bang-off-bang,CPT-024):固定 T 大于最小时间时,最小功解为
“加速–巡航–减速”三相位:u=+u_max on [0,τ],u=0 on [τ, T−τ],
u=−u_max on [T−τ, T],其中 τ 满足 −τ² + Tτ = x_target/u_max。
本算例取 x_target=1、u_max=1、T=3 ⇒ τ=(T−√(T²−4))/2≈0.381966,
切换时刻 t₁=τ、t₂=T−τ,最小功 = u_max·2τ ≈ 0.763932。
(T 取最小值 2 时三相位退化为两相位 bang-bang;实测梯形配点在
T=2 处离散可达集略小于 1,LP 不可行——离散化误差的直观体现,
故本算例取 T=3。)

确定性:给定参数下 HiGHS 可复现;输出数值统一四舍五入到 9 位。
输出:examples/EX-001.json(机器结果)+ examples/EX-001.svg(图)。

运行:examples/.venv/bin/python examples/EX-001.py
"""
from __future__ import annotations

import hashlib
import json
import math
from pathlib import Path

import numpy as np
from scipy.optimize import linprog

HERE = Path(__file__).resolve().parent
X_TARGET = 1.0
U_MAX = 1.0
T_FINAL = 3.0                                        # > 最小时间 2√(x/u)
# 三相位解析解:τ² - T·τ + x_target/u_max = 0
TAU = (T_FINAL - math.sqrt(T_FINAL**2
                           - 4.0 * X_TARGET / U_MAX)) / 2.0
T_SWITCH1 = TAU                                      # 加速 → 巡航
T_SWITCH2 = T_FINAL - TAU                            # 巡航 → 减速
EFFORT_ANALYTIC = U_MAX * 2.0 * TAU                  # ≈ 0.763932023
MESHES = [10, 20, 40, 80, 160]


def analytic(t):
    """解析 bang-off-bang 解(标量或 ndarray)。"""
    t = np.asarray(t, dtype=float)
    phase1 = t <= T_SWITCH1
    phase3 = t >= T_SWITCH2
    phase2 = ~phase1 & ~phase3
    x = np.where(phase1, t**2 / 2.0,
                 np.where(phase2, TAU**2 / 2.0 + TAU * (t - TAU),
                          X_TARGET - (T_FINAL - t) ** 2 / 2.0))
    v = np.where(phase1, t, np.where(phase2, TAU, T_FINAL - t))
    u = np.where(phase1, U_MAX, np.where(phase2, 0.0, -U_MAX))
    return x, v, u


def solve_collocation(n):
    """梯形配点 + 松弛变量 → LP;返回解与诊断量。"""
    h = T_FINAL / n
    nv = n + 1
    # 变量分块:x(0..n), v(0..n), u(0..n), s(0..n)
    def ix(k):   # x
        return k

    def iv(k):
        return nv + k

    def iu(k):
        return 2 * nv + k

    def is_(k):
        return 3 * nv + k

    nvar = 4 * nv
    c = np.zeros(nvar)
    w = np.full(nv, h)
    w[0] = w[-1] = h / 2.0
    for k in range(nv):
        c[is_(k)] = w[k]

    # 等式约束
    rows, rhs = [], []

    def eq(coefs, b):
        r = np.zeros(nvar)
        for i, val in coefs:
            r[i] += val
        rows.append(r)
        rhs.append(b)

    eq([(ix(0), 1.0)], 0.0)
    eq([(iv(0), 1.0)], 0.0)
    eq([(ix(n), 1.0)], X_TARGET)
    eq([(iv(n), 1.0)], 0.0)
    for k in range(n):
        eq([(ix(k + 1), 1.0), (ix(k), -1.0),
            (iv(k), -h / 2.0), (iv(k + 1), -h / 2.0)], 0.0)
        eq([(iv(k + 1), 1.0), (iv(k), -1.0),
            (iu(k), -h / 2.0), (iu(k + 1), -h / 2.0)], 0.0)
    a_eq = np.array(rows)
    b_eq = np.array(rhs)

    # 不等式:u_k - s_k <= 0, -u_k - s_k <= 0
    ub_rows = []
    for k in range(nv):
        r = np.zeros(nvar)
        r[iu(k)] = 1.0
        r[is_(k)] = -1.0
        ub_rows.append(r)
        r = np.zeros(nvar)
        r[iu(k)] = -1.0
        r[is_(k)] = -1.0
        ub_rows.append(r)
    a_ub = np.array(ub_rows)
    b_ub = np.zeros(len(ub_rows))

    bounds = ([(None, None)] * nv + [(None, None)] * nv
              + [(-U_MAX, U_MAX)] * nv + [(0.0, None)] * nv)

    res = linprog(c, A_ub=a_ub, b_ub=b_ub, A_eq=a_eq, b_eq=b_eq,
                  bounds=bounds, method="highs")
    if not res.success:
        raise RuntimeError(f"N={n}: LP failed: {res.message}")

    z = res.x
    t = np.linspace(0.0, T_FINAL, nv)
    x = z[[ix(k) for k in range(nv)]]
    v = z[[iv(k) for k in range(nv)]]
    u = z[[iu(k) for k in range(nv)]]
    xa, va, ua = analytic(t)
    # 切换点处 u 的两侧取值不同,误差统计排除切换邻域节点
    away = ((np.abs(t - T_SWITCH1) > h)
            & (np.abs(t - T_SWITCH2) > h))

    def cross(level):
        """u 首次穿越 level 的插值时刻(相邻节点线性插值)。"""
        for k in range(nv - 1):
            a, b = u[k] - level, u[k + 1] - level
            if a * b < 0:
                return float(t[k] + (t[k + 1] - t[k]) * (-a) / (b - a))
        return None

    t1 = cross(U_MAX / 2.0)
    t2 = cross(-U_MAX / 2.0)
    return {
        "N": n,
        "h": round(h, 9),
        "objective": round(float(res.fun), 9),
        "objective_err_vs_analytic": round(float(res.fun)
                                           - EFFORT_ANALYTIC, 9),
        "switch1_time": round(t1, 9) if t1 is not None else None,
        "switch1_err": (round(t1 - T_SWITCH1, 9)
                        if t1 is not None else None),
        "switch2_time": round(t2, 9) if t2 is not None else None,
        "switch2_err": (round(t2 - T_SWITCH2, 9)
                        if t2 is not None else None),
        "max_abs_u": round(float(np.max(np.abs(u))), 9),
        "x_err_inf": round(float(np.max(np.abs(x - xa))), 9),
        "v_err_inf": round(float(np.max(np.abs(v - va))), 9),
        "u_err_inf_away_from_switch": round(
            float(np.max(np.abs(u[away] - ua[away]))) if away.any()
            else 0.0, 9),
        "terminal_x": round(float(x[-1]), 9),
        "terminal_v": round(float(v[-1]), 9),
    }, (t, x, v, u)


def svg_plot(t, x, v, u, path):
    """手写 SVG:三段面板(x/v/u),数值解实线 + 解析解虚线。"""
    W, H = 760, 560
    pad_l, pad_r, pad_t, pad_b = 56, 16, 26, 30
    pw = W - pad_l - pad_r
    panel_h = (H - pad_t - pad_b - 2 * 14) / 3.0
    xa, va, ua = analytic(t)
    panels = [
        ("x(t)", x, xa, (0.0, max(1.0, float(np.max(x))))),
        ("v(t)", v, va, (min(0.0, float(np.min(v))),
                         max(1.0, float(np.max(v))))),
        ("u(t)", u, ua, (-1.2, 1.2)),
    ]

    def pts(arr, lo, hi, y0):
        n = len(arr)
        out = []
        for i, val in enumerate(arr):
            px = pad_l + pw * i / max(1, n - 1)
            py = y0 + panel_h - panel_h * (val - lo) / (hi - lo)
            out.append(f"{px:.2f},{py:.2f}")
        return " ".join(out)

    parts = [f'<svg xmlns="http://www.w3.org/2000/svg" '
             f'width="{W}" height="{H}" viewBox="0 0 {W} {H}" '
             f'font-family="sans-serif" font-size="12">',
             f'<rect width="{W}" height="{H}" fill="#ffffff"/>']
    for j, (label, num, ana, (lo, hi)) in enumerate(panels):
        y0 = pad_t + j * (panel_h + 14)
        parts.append(f'<rect x="{pad_l}" y="{y0:.2f}" width="{pw}" '
                     f'height="{panel_h:.2f}" fill="#fafafa" '
                     f'stroke="#dddddd"/>')
        if lo < 0 < hi:      # 零线
            zy = y0 + panel_h - panel_h * (0 - lo) / (hi - lo)
            parts.append(f'<line x1="{pad_l}" y1="{zy:.2f}" '
                         f'x2="{pad_l + pw}" y2="{zy:.2f}" '
                         f'stroke="#e0e0e0"/>')
        parts.append(f'<polyline points="{pts(ana, lo, hi, y0)}" '
                     f'fill="none" stroke="#1f77b4" stroke-width="1.4" '
                     f'stroke-dasharray="5,4"/>')
        parts.append(f'<polyline points="{pts(num, lo, hi, y0)}" '
                     f'fill="none" stroke="#d62728" stroke-width="2"/>')
        parts.append(f'<text x="6" y="{y0 + panel_h / 2:.2f}" '
                     f'fill="#333">{label}</text>')
        parts.append(f'<text x="{pad_l}" y="{y0 - 6:.2f}" '
                     f'fill="#666">{lo:g}{hi:g}</text>')
    parts.append(f'<text x="{pad_l}" y="{H - 8}" fill="#666">'
                 f't(红:配点数值解;蓝虚线:解析 bang-bang 解)'
                 f'</text>')
    parts.append("</svg>")
    path.write_text("\n".join(parts) + "\n", encoding="utf-8")


def main():
    cases = []
    finest = None
    for n in MESHES:
        case, series = solve_collocation(n)
        cases.append(case)
        finest = series
    out = {
        "problem": {
            "name": "minimum-effort double integrator",
            "x_target": X_TARGET,
            "u_max": U_MAX,
            "T": T_FINAL,
            "analytic": {
                "u": ("bang-off-bang: +u_max on [0,tau], 0 on "
                      "[tau,T-tau], -u_max on [T-tau,T]"),
                "tau": round(TAU, 9),
                "switch_times": [round(T_SWITCH1, 9),
                                 round(T_SWITCH2, 9)],
                "effort": round(EFFORT_ANALYTIC, 9),
            },
        },
        "transcription": {
            "method": "trapezoidal collocation",
            "slack_for_abs_control": True,
            "lp_solver": "scipy.optimize.linprog(method=highs)",
            "variables_per_node": ["x", "v", "u", "sigma"],
        },
        "cases": cases,
    }
    jpath = HERE / "EX-001.json"
    jpath.write_text(json.dumps(out, ensure_ascii=False, indent=2,
                                sort_keys=True) + "\n", encoding="utf-8")
    svg_plot(*finest, HERE / "EX-001.svg")
    digest = hashlib.sha256(jpath.read_bytes().rstrip()).hexdigest()
    print(f"wrote {jpath.name} (result-hash sha256:{digest})")
    print(f"wrote EX-001.svg")
    for c in cases:
        print(f"  N={c['N']:>3}  obj={c['objective']:.9f}  "
              f"t1={c['switch1_time']:.6f}  t2={c['switch2_time']:.6f}  "
              f"x_err={c['x_err_inf']:.2e}  "
              f"u_err={c['u_err_inf_away_from_switch']:.2e}")


if __name__ == "__main__":
    main()

关联(6)