MCM520 ← 资料站首页 电工杯 2019 B 范文:配电网重构(33 节点合成系统 · 前推回代潮流 + 单环枚举 + 双步全枚举校验 + 电压安全约束 + 负荷敏感性) 打开交互阅读器 →

电工杯 2019 B 范文:配电网重构(33 节点合成系统 · 前推回代潮流 + 单环枚举 + 双步全枚举校验 + 电压安全约束 + 负荷敏感性)

一、摘要

针对电工杯 2019 年 B 题「配电网重构」,本文以一套 33 节点合成配电网为对象(变电站母线 1 座、主干馈线 4 条共 27 个负荷节点、末端侧枝 5 个,32 条常闭支路构成辐射状基态,另有 5 条常开联络开关构成 5 个基本环),完整走通「网络建模 → 潮流计算 → 重构寻优 → 安全校验 → 灵敏度分析」的全链条。潮流计算采用辐射网专用的前推回代法(backward-forward sweep):回推过程沿树的逆序累加支路电流 I˙n=∑c∈child(n)I˙c+conj⁡(Sn/V˙n)\dot I_n=\sum_{c\in child(n)}\dot I_c+\operatorname{conj}(S_n/\dot V_n),前推过程沿正序更新节点电压 V˙n=V˙par−Zpar,nI˙n\dot V_n=\dot V_{par}-Z_{par,n}\dot I_n,以 max|ΔV|<10⁻⁹ 为收敛判据,基态 35 次迭代收敛。重构寻优采用单环枚举:任一联络开关闭合后与基态辐射网构成唯一基本环,环内择一条支路断开即可在保持辐射性的前提下生成候选方案,5 条联络共产生 43 个候选,经电压安全域 [0.95, 1.05] pu 筛选后剩 20 个可行方案。结果表明:最优方案为断开 25-26 支路、投入联络 T3(19-26),把重载馈线 4 尾部负荷改由轻载馈线 3 供电,网损从 262.65 kW 降至 223.43 kW,降损 14.93%;全网最低电压由 0.9600 pu(节点 27)抬升至 0.9919 pu,改善 0.0319 pu。为排除「多步投切更优」的可能,本文再做双步顺序全枚举(864 个组合),最优解与单步完全一致,证实该系统上单步重构即达全局最优。方案空间的结构性发现同样重要:20 个可行方案中仅 2 个真正优于基态,而「闭合 T3 却错误断开 0-15」的最差方案使网损飙升至 776.9 kW(约为基态 3 倍)、电压跌至 0.8340 pu——联络开关是双刃剑,投切方向比投切本身更重要。负荷敏感性显示负荷水平从 80% 升至 120% 时降损率从 14.29% 单调升至 15.64%,说明重构价值随负荷增长而放大。全文纯标准库实现、固定种子可复现,正文、配图、附录与真源数据四路数字一致。

二、问题重述

配电网正常运行时必须保持辐射状(树形)结构,以简化继电保护整定与故障隔离;但同时在城市电网中预留大量常开联络开关,用于故障转供与负荷调整。配电网重构(network reconfiguration)就是在正常运方下通过开合联络开关与分段开关,改变网络的树形结构,在不违反辐射性与电压安全的前提下追求运行指标的优化,经典目标是有功网损最小。赛题的实质可拆解为三个子问题:

  1. 潮流子问题:给定网络结构与负荷,如何高效计算各支路潮流、节点电压与总网损?辐射状弱网格结构使牛顿法雅可比矩阵病态,需要专用算法。
  2. 优化子问题:开关投切组合是组合优化问题(n 条候选支路的 0-1 组合呈指数增长),如何在保证辐射约束的前提下高效搜索最优结构?
  3. 评估子问题:最优方案相对于基态改善了什么?在负荷波动下是否稳健?哪些方案看似可行实则更差?

本文针对上述三问依次建立前推回代潮流模型、单环枚举 + 电压筛选的重构模型与双步全枚举校验模型,并以负荷 80%~120% 五档扫描检验方案的稳健性。

三、模型假设与符号

  • H1(合成系统):官方详细数据未公开,本文按典型 10 kV 城市配电网结构合成 33 节点系统并固定随机种子(SEED=20260903),拓扑、线路参数与负荷均确定性生成,方法与结论对真实系统同样适用。
  • H2(对称稳态):系统三相对称,以单相等值电路分析;运行处于稳态工频,不计电磁暂态。
  • H3(恒功率负荷):各节点负荷为静态恒功率模型,功率因数统一取 0.95(Q=Ptan⁡(arccos⁡0.95)Q=P\tan(\arccos 0.95)),短时间尺度内不随电压变化。
  • H4(理想开关):开关操作无成本、无时限,重构一次性完成,任意时刻网络保持辐射状(连通且恰有 N−1N-1 条支路)。
  • H5(电压安全域):各节点电压幅值须满足 0.95 pu≤∣V˙∣≤1.05 pu0.95\ \mathrm{pu}\le|\dot V|\le 1.05\ \mathrm{pu},变电站母线电压恒定 V0=1.05V_0=1.05 pu。

主要符号:标幺制下取 Sbase=10S_\mathrm{base}=10 MVA、Vbase=10V_\mathrm{base}=10 kV,故 Zbase=10 ΩZ_\mathrm{base}=10\ \Omega;单位线路阻抗 r0=0.45 Ω/kmr_0=0.45\ \Omega/\mathrm{km}、x0=0.30 Ω/kmx_0=0.30\ \Omega/\mathrm{km},联络线统一 1.25 km。节点 nn 的负荷记 (Pn,Qn)(P_n,Q_n)(kW/kvar),电压相量 V˙n\dot V_n,支路电流 I˙n\dot I_n,全网有功网损 Ploss=∑(p,n)∈E∣Ipn∣2Rpn⋅Sbase×103P_\mathrm{loss}=\sum_{(p,n)\in E}|I_{pn}|^2 R_{pn}\cdot S_\mathrm{base}\times10^3(kW)。

四、系统建模与前推回代潮流(问题一)

4.1 网络的图模型

把配电网抽象为无向图 G=(V,E)G=(V,E):V={0,1,…,32}V=\{0,1,\dots,32\} 为节点集(0 号为变电站),EE 由 32 条常闭支路与 5 条常开联络支路组成。基态运行时网络恰为一棵以 0 号为根的支撑树(spanning tree),每条联络弦 tkt_k 与树拼合后构成一个唯一的基本环——这是后续单环枚举的结构基础。4 条主干馈线的负荷分布刻意做成不均衡:馈线 1~4 的干线负荷分别为 1183.0、1522.0、1365.0、1867.1 kW,馈线 4 最重且线路阻抗系数最大,为重构预留了明确的优化空间(图 1)。

图1 33节点合成配电网拓扑

4.2 标幺化与前推回代

取 Sbase=10S_\mathrm{base}=10 MVA、Vbase=10V_\mathrm{base}=10 kV 把阻抗与功率折算为标幺值。辐射网的潮流方程天然具有「树递归」结构,前推回代法据此交替执行两步:

  • 回推电流(自叶向根):I˙n(k)=∑c ∈ child(n)I˙c(k)+(Pn+jQnV˙n(k−1))∗\dot I_n^{(k)}=\displaystyle\sum_{c\,\in\,\mathrm{child}(n)}\dot I_c^{(k)}+\left(\frac{P_n+jQ_n}{\dot V_n^{(k-1)}}\right)^{*},其中上标 (k)(k) 为迭代轮次;
  • 前推电压(自根向叶):V˙n(k)=V˙par(n)(k)−Zpar(n),n I˙n(k)\dot V_n^{(k)}=\dot V_{\mathrm{par}(n)}^{(k)}-Z_{\mathrm{par}(n),n}\,\dot I_n^{(k)};
  • 收敛判据:max⁡n∣V˙n(k)−V˙n(k−1)∣<10−9\max_n|\dot V_n^{(k)}-\dot V_n^{(k-1)}|<10^{-9}。

该方法每轮只需 O(N)O(N) 次递归,避免了雅可比矩阵的形成与求逆,对辐射状弱网格配电网收敛迅速且数值稳定——基态工况 35 次迭代即达到 10⁻⁹ 精度。网损按支路电阻上的焦耳热累加:Ploss=∑∣I∣2RP_\mathrm{loss}=\sum |I|^2 R(标幺值乘以 Sbase×103S_\mathrm{base}\times10^3 化为 kW)。

4.3 基态电压剖面

图2 基态电压剖面

图 2 给出基态全网电压幅值剖面。变电站出口 1.05 pu,沿馈线方向单调下降;重载馈线 4 因负荷与阻抗双重叠加,压降显著陡于其余三条馈线,全网电压谷点出现在其末端节点 27,幅值 0.9600 pu——距 0.95 pu 的下限只剩 0.01 pu 裕度,属于典型的「合规但紧张」状态。这一紧张裕度正是后文重构降损空间的伏笔:任何能缩短重载馈线电气距离的结构调整,都会同时缓解网损与电压谷点两个痛点。

五、基态运行分析与瓶颈识别(问题二)

图3 基态支路损耗Top10

图 3 按损耗大小列出基态 Top10 支路:0-21(51.7 kW)、22-23(42.1 kW)、21-22(31.7 kW) 三条支路合计占全网损耗的 47.7%,且全部位于馈线 4 及其供电走廊(0-8 为次重的馈线 2 首端,20.9 kW)。损耗的平方律放大效应在此清晰可见——电流越大,同样的电阻消耗平方倍的功率。由此可以预判:重构的最优方向应当是把馈线 4 中后段的负荷转移到临近的轻载馈线,而不是在轻载馈线之间做无谓的对倒。表 1 汇总基态关键指标。

表 1 基态运行指标

指标 数值
总负荷 6677.6 kW / 2194.8 kvar
有功网损 / 网损率 262.65 kW / 3.93%
最低电压 0.9600 pu @ 节点 27
潮流收敛迭代 35 次(tol = 10⁻⁹)

六、重构方案空间与最优投切策略(问题三)

6.1 单环枚举:结构可行的候选生成

重构操作的组合空间理论上很大,但「一次投切保持辐射」的约束把它压缩得非常干净:闭合任一条联络弦 tkt_k,网络出现唯一基本环;在环内断开任意一条树支,网络恢复辐射。因此每个联络恰好贡献「环长」个候选方案,5 条联络的基本环长度合计为 43,即 43 个结构性候选——不多不少、无一遗漏、无需查重。这是辐射图「树 + 弦」二重性直接给出的枚举框架,比启发式的随机开关翻转更有理论保证。

对每个候选调用前推回代潮流并做电压安检后,得到方案空间全景(图 4、表 2):43 个候选中 23 个因电压越限被否决,仅 20 个可行;可行方案的网损分布在 223.4~366.7 kW,其中只有 2 个真正优于基态的 262.65 kW——「电压合格」与「值得投切」之间隔着巨大的鸿沟。

图4 方案空间可行性分布

表 2 单环方案空间统计

统计项 数值
结构候选总数 43
电压越限否决 23
可行方案 20(网损 223.4 ~ 366.7 kW)
优于基态 仅 2 个
最差方案 776.9 kW / 0.8340 pu @15

6.2 最优方案及其物理机制

最优方案为断开支路 25-26、投入联络 T3(19-26):节点 26 及其下游负荷原先由重载馈线 4 经 21→…→25→26 的长走廊供电,重构后改由轻载馈线 3 经联络 T3 直接供电(图 5)。其成因可以在联络开关两端的基态电压差中找到定量解释:T3 两端电压 1.0208 pu(节点 19)与 0.9616 pu(节点 26)之差高达 0.0592 pu,为 5 条联络之最——电压差大意味着两端馈线的负载不均衡程度深,闭环转移负荷所能「抹平」的损耗也就越大。相比之下 T1、T4 两端电压差不足 0.014 pu,切换近乎无益,这解释了为什么多数候选方案平平无奇甚至更差。

图5 最优重构方案示意

反面的教训同样深刻:同样是闭合 T3,若断开的不是 25-26 而是 0-15(表 2 最差方案),馈线 3 将失去主电源、被迫经 T3 由馈线 4 反向远距离供电,网损飙到 776.9 kW、电压跌穿 0.8340 pu——同一联络、不同断口,结果相差 3.5 倍。这说明联络开关本身无所谓好坏,投切方案的价值完全取决于断口选择是否顺应潮流的自然流向。

表 3 最优方案指标汇总

指标 基态 重构后 变化
有功网损 262.65 kW 223.43 kW −14.93%
最低电压 0.9600 pu @27 0.9919 pu @25 +0.0319 pu
潮流迭代次数 35 28 —
操作 — 断 25-26,合 T3(19-26) 2 次开关动作

七、重构效果校验与全局最优性

图6 重构前后电压剖面对比

图 6 叠画重构前后两条电压剖面:重构后全线电压整体抬升,抬升幅度沿馈线 4 尾部最大(节点 26 从 0.9616 升至约 0.99 pu 一带),全网电压谷点从节点 27 移到节点 25、幅值 0.9919 pu,距下限的安全裕度由 0.0100 pu 扩大到 0.0419 pu——扩大的电压裕度本身就是未来负荷增长的宝贵资源。图 7 的网损阶梯则显示:基态 262.65 kW → 单步最优 223.43 kW,一步到位。

图7 网损对比

为回答「是否存在更优的多步投切序列」,本文在全部 20 个可行单步方案之上再各做一轮单环投切,形成双步顺序全枚举,共评估 864 个两步组合。结果显示两步最优仍为「断 25-26、投 T3」,网损 223.43 kW,第二步边际降损为 0.00%。由于任何多步序列的中间结构都可用有限次单环投切覆盖,这一结果给出了强证据:在本系统中,单步重构已达全局最优,无需为额外的开关动作付出操作代价。

八、负荷敏感性分析

图8 负荷敏感性

配电网负荷随季节与昼夜自然波动,重构方案的价值必须在波动下重新审视。将全网负荷等比例缩放至 80%~120% 五档,每档分别重算基态与重构后的最优网损(表 4、图 8)。

表 4 负荷水平敏感性

负荷水平 基态网损 (kW) 重构后 (kW) 降损率
80% 164.0 140.5 14.29%
90% 210.1 179.4 14.60%
100% 262.7 223.4 14.93%
110% 321.9 272.8 15.28%
120% 388.2 327.5 15.64%

两条规律清晰浮现:其一,降损绝对量随负荷近似按平方律增长(36.0 → 60.7 kW),因为转移的电流变大,损耗差的平方效应放大;其二,降损率亦单调上升(14.29% → 15.64%),说明负荷越重,「削峰填谷」式结构调整的相对价值越大。换言之,本方案不仅适用于当前工况,而且在负荷增长情景下价值不降反升,具备良好的前瞻稳健性。

九、模型检验、优缺点与拓展

收敛性与精度:所有潮流均在 10⁻⁹ 容差下于 28~35 次迭代内收敛,未出现振荡或发散;枚举过程对每个候选独立调用潮流,结果可逐位复现(固定种子)。

枚举完备性:单环枚举对「单次投切保持辐射」的操作类是完备的(树+弦二重性保证 43 个候选不重不漏);双步全枚举 864 组合进一步排除了多步序列更优的可能。相比遗传算法、粒子群等启发式方法,枚举法在本规模(候选 ≤ 数百)下给出的是可证明的最优解而非「较优解」,且计算量完全可接受。

主要优点:① 模型链路每一环节(拓扑、参数、负荷、种子)确定性可复现,正文、配图、附录、真源四路一致;② 前推回代 + 单环枚举的组合零外部依赖,任何 Python 环境可直接运行;③ 方案空间全景分析揭示了「可行 ≠ 更优」「联络双刃剑」等结构性规律,具有超出本题的方法论价值。

局限与改进方向:① 恒功率负荷假设忽略电压静特性,重载下可能高估压降,可换用 ZIP 模型;② 未计及开关操作寿命成本与动作次数约束,多时段滚动重构时需引入时间耦合;③ 单时段快照无法响应分布式电源出力的时变性,后续可将风电/光伏出力曲线与储能充放纳入多时段动态重构框架,或对负荷相关性做场景化蒙特卡洛检验。

十、结论

本文围绕电工杯 2019 B 题建立了「图模型 + 前推回代潮流 + 单环枚举 + 双步校验」的配电网重构完整方案。核心结论:① 33 节点合成系统基态网损 262.65 kW(网损率 3.93%),电压谷点 0.9600 pu 位于重载馈线 4 末端的节点 27,损耗前三支路全部落在该馈线走廊,瓶颈定位明确;② 43 个单环候选经电压安检剩 20 个可行,最优方案「断 25-26、合 T3(19-26)」利用全网最大的联络两端电压差(0.0592 pu)实现负荷转移,网损降至 223.43 kW(降损 14.93%),电压谷点抬升至 0.9919 pu(+0.0319 pu);③ 864 个两步组合证明单步即全局最优;④ 负荷 80%~120% 扫描显示降损率随负荷单调升至 15.64%,方案具备前瞻稳健性。方法论层面,「电压差选联络、潮流流向选拢口」的物理判据与「可行 ≠ 更优」的方案空间画像,可为同类重构问题的快速预判提供参考。

参考文献

[1] Baran M E, Wu F F. Network reconfiguration in distribution systems for loss reduction and load balancing[J]. IEEE Transactions on Power Delivery, 1989, 4(2): 1401–1407.

[2] 王守相, 王成山. 现代配电系统分析[M]. 北京: 高等教育出版社, 2014.

[3] 张伯明, 陈寿孙, 严正. 高等电力网络分析[M]. 2版. 北京: 清华大学出版社, 2007.

[4] Messalti S, Harrag A, Loukriz A. A new variable step size neural networks-based controller for power loss minimization in electrical distribution networks[J]. Journal of Electrical Systems, 2017.

[5] 中国电机工程学会. 全国大学生电工数学建模竞赛历年赛题汇编[M]. 北京: 中国电力出版社, 2020.

附录:核心 Python 实现

以下代码为真源 tools/gen_dgcup2019b.py 的等价核心摘录(固定种子、纯标准库),可在本文件所在目录直接运行,输出与正文全部数字一致。

# -*- coding: utf-8 -*-
"""33 节点配电网重构:前推回代潮流 + 单环/双步枚举核心(纯标准库)。"""
import math
import random

SEED = 20260903
V0 = 1.05            # 变电站母线电压 (pu)
VMIN, VMAX = 0.95, 1.05
TOL, MAXIT = 1e-9, 60
SB = 10.0            # 功率基值 10 MVA(Zbase = 10 Ω)

ROOT = 0
TRUNKS = [[1, 2, 3, 4, 5, 6, 7],
          [8, 9, 10, 11, 12, 13, 14],
          [15, 16, 17, 18, 19, 20],
          [21, 22, 23, 24, 25, 26, 27]]
LATERALS = [(2, 28), (9, 29), (17, 30), (24, 31), (26, 32)]
TIES = [(5, 12), (12, 19), (19, 26), (28, 9), (30, 23)]


def build_system():
    """返回 nodes、base_tree(32 边)、z(边->R,X pu)、P/Q(节点 kW/kvar)。"""
    tree = []
    for tg in TRUNKS:
        prev = ROOT
        for v in tg:
            tree.append((prev, v))
            prev = v
    tree.extend(LATERALS)
    zb = SB
    ZM = [1.0, 1.0, 1.1, 1.25]
    trunk_of = {}
    for ti, tg in enumerate(TRUNKS):
        for v in tg:
            trunk_of[v] = ti
    z = {}
    for k, (u, v) in enumerate(sorted(tree)):
        zm = ZM[trunk_of.get(v, 0)]
        ln = (0.7 + ((k * 5) % 9) / 6.0) * zm     # 0.7 ~ 2.7 km
        z[(u, v)] = (round(0.45 * ln / zb, 5),
                     round(0.30 * ln / zb, 5))
    for i, t in enumerate(TIES):
        tn = (t[0], t[1]) if t[0] < t[1] else (t[1], t[0])
        z[tn] = (round(0.45 * 1.25 / zb, 5),
                 round(0.30 * 1.25 / zb, 5))
    rng = random.Random(SEED)
    tan_phi = math.tan(math.acos(0.95))
    KL = 2.1
    LS = [0.8, 1.0, 1.05, 1.3]
    pat = {}
    for ti, tg in enumerate(TRUNKS):
        m = len(tg)
        for idx, v in enumerate(tg):
            pat[v] = (55.0 + 85.0 * math.sin(math.pi * idx / (m - 1))) \
                * KL * LS[ti]
    for (_, v) in LATERALS:
        pat[v] = 70.0 * KL
    P, Q = {}, {}
    for v in sorted(pat):
        p = pat[v] * (1.0 + rng.gauss(0.0, 0.06))
        P[v] = round(p, 2)
        Q[v] = round(p * tan_phi, 2)
    return list(range(33)), tree, z, P, Q


def ne(e):
    return (e[0], e[1]) if e[0] < e[1] else (e[1], e[0])


def _span(tree_edges):
    """从根 BFS:返回 adj/par/order;不连通或边数≠32 返回 None。"""
    if len(set(ne(e) for e in tree_edges)) != 32:
        return None
    adj = {}
    for (u, v) in tree_edges:
        adj.setdefault(u, []).append(v)
        adj.setdefault(v, []).append(u)
    par = {ROOT: None}
    order = [ROOT]
    dq = [ROOT]
    while dq:
        u = dq.pop(0)
        for v in adj.get(u, []):
            if v not in par:
                par[v] = u
                order.append(v)
                dq.append(v)
    if len(order) != 33:
        return None
    return adj, par, order


def power_flow(tree_edges, z, P, Q):
    """前推回代潮流。返回 dict(loss_kW/vmin/vmin_node/iters) 或 None。"""
    sp = _span(tree_edges)
    if sp is None:
        return None
    adj, par, order = sp
    down = list(reversed(order))
    V = {n: V0 for n in order}

    def zk(u, v):
        return (u, v) if (u, v) in z else (v, u)

    for it in range(1, MAXIT + 1):
        cur = {n: 0j for n in order}
        for n in down:
            tot = 0j
            if n != ROOT:
                s_pu = complex(P[n] / 1000.0 / SB, Q[n] / 1000.0 / SB)
                tot += complex(s_pu.real, -s_pu.imag) / V[n]
            for c in adj.get(n, []):
                if par.get(c) == n:
                    tot += cur[c]
            cur[n] = tot
        err = 0.0
        newV = dict(V)
        for n in order[1:]:
            r, x = z[zk(par[n], n)]
            vn = V[par[n]] - complex(r, x) * cur[n]
            err = max(err, abs(vn - V[n]))
            newV[n] = vn
        V = newV
        if err < TOL:
            break
    loss_kw = 0.0
    for n in order[1:]:
        r, _ = z[zk(par[n], n)]
        loss_kw += (abs(cur[n]) ** 2) * r * SB * 1000.0
    vm = {n: abs(V[n]) for n in order}
    vmin, vmin_node = V0, ROOT
    for n in order:
        if n != ROOT and vm[n] < vmin:
            vmin, vmin_node = vm[n], n
    return dict(loss_kW=loss_kw, vmin=vmin, vmin_node=vmin_node,
                iters=it)


def cycle_of(chord, tree_edges):
    """弦在辐射网上构成的基本环(树边集合)。不连通返回 None。"""
    sp = _span(tree_edges)
    if sp is None:
        return None
    par = sp[1]

    def path_to_root(v):
        ps = []
        while v != ROOT:
            ps.append((par[v], v))
            v = par[v]
        return ps

    pa, pb = path_to_root(chord[0]), path_to_root(chord[1])
    sa = set(pa)
    common = None
    for e in pb:
        if e in sa:
            common = e
            break
    cyc = []
    for e in pa:
        if e == common:
            break
        cyc.append(e)
    for e in pb:
        if e == common:
            break
        cyc.append(e)
    return cyc


def eval_config(edges, z, P, Q):
    pf = power_flow(edges, z, P, Q)
    if pf is None:
        return None
    if pf["vmin"] < VMIN or pf["vmin"] > VMAX:
        return None
    return pf


def describe(cfg, base_tree):
    """把结构集合翻译成「断…投联络T…」操作描述。"""
    base_set = {ne(e) for e in base_tree}
    closed_set = {ne(e) for e in cfg}
    opened = sorted(base_set - closed_set)
    closed = []
    for i, t in enumerate(TIES):
        if ne(t) in closed_set:
            closed.append("T%d(%d-%d)" % (i + 1, t[0], t[1]))
    return "断" + "+".join("%d-%d" % o for o in opened) + \
           " 投" + ",".join(closed)


def enumerate_single(z, P, Q, base_tree):
    valid, invalid, total = [], 0, 0
    for tie in TIES:
        cyc = cycle_of(tie, base_tree)
        for f in cyc:
            total += 1
            cfg = [e for e in base_tree if e != f] + [tie]
            pf = eval_config(cfg, z, P, Q)
            if pf is None:
                invalid += 1
                continue
            valid.append(dict(open=f, tie=tie, loss=pf["loss_kW"],
                              vmin=pf["vmin"], vmin_node=pf["vmin_node"],
                              cfg=frozenset(cfg)))
    valid.sort(key=lambda d: d["loss"])
    return valid, invalid, total


def enumerate_two(z, P, Q, base_tree, singles):
    """双步顺序全枚举:全程归一化边,避免方向不一致导致删除失效。"""
    full = [ne(e) for e in base_tree] + [ne(t) for t in TIES]
    base_fs = frozenset(ne(e) for e in base_tree)
    best, combos = None, 0
    for s1 in singles:
        tree1 = [ne(e) for e in s1["cfg"]]
        in_cfg = set(tree1)
        chords2 = [e for e in full if e not in in_cfg]
        for ch in chords2:
            cyc = cycle_of(ch, tree1)
            if cyc is None:
                continue
            for f in cyc:
                if ne(f) == ch:
                    continue
                combos += 1
                cfg = frozenset(ne(e) for e in tree1
                                if ne(e) != ne(f)) | {ch}
                if cfg == base_fs:
                    continue
                pf = eval_config(sorted(cfg), z, P, Q)
                if pf is None:
                    continue
                if best is None or pf["loss_kW"] < best["loss"]:
                    best = dict(loss=pf["loss_kW"], vmin=pf["vmin"],
                                vmin_node=pf["vmin_node"], cfg=cfg,
                                last_open=f, last_tie=ch)
    return best, combos


if __name__ == "__main__":
    nodes, base_tree, z, P, Q = build_system()
    tp = sum(P.values())
    tq = sum(Q.values())
    base = power_flow(base_tree, z, P, Q)
    print("== 系统 ==")
    print("节点 33 支路 32 联络 5 总负荷 %.1f kW / %.1f kvar"
          % (tp, tq))
    print("== 基态潮流 ==")
    print("网损 %.2f kW 网损率 %.2f%% 最低电压 %.4f pu @节点%d 迭代 %d 次"
          % (base["loss_kW"], base["loss_kW"] / tp * 100.0,
             base["vmin"], base["vmin_node"], base["iters"]))
    print("== 单环枚举 ==")
    singles, invalid, total = enumerate_single(z, P, Q, base_tree)
    print("候选 %d 可行 %d 低电压剔除 %d" % (total, len(singles), invalid))
    b1 = singles[0]
    red1 = (base["loss_kW"] - b1["loss"]) / base["loss_kW"] * 100.0
    print("单步最优 %s 网损 %.2f kW 降损 %.2f%%"
          % (describe(b1["cfg"], base_tree), b1["loss"], red1))
    print("== 双步枚举 ==")
    b2, combos = enumerate_two(z, P, Q, base_tree, singles)
    red2 = (base["loss_kW"] - b2["loss"]) / base["loss_kW"] * 100.0
    red2b = (b1["loss"] - b2["loss"]) / b1["loss"] * 100.0
    print("评估组合 %d 两步最优 %s 网损 %.2f kW 累计降损 %.2f%% 第二步再降 %.2f%%"
          % (combos, describe(b2["cfg"], base_tree), b2["loss"], red2, red2b))
    pf2 = power_flow(sorted(b2["cfg"]), z, P, Q)
    print("重构后最低电压 %.4f pu @节点%d 提升 %.4f"
          % (pf2["vmin"], pf2["vmin_node"], pf2["vmin"] - base["vmin"]))
    print("== 负荷敏感性 ==")
    for lv in (0.8, 0.9, 1.0, 1.1, 1.2):
        Ps = {k: v * lv for k, v in P.items()}
        Qs = {k: v * lv for k, v in Q.items()}
        bl = power_flow(base_tree, z, Ps, Qs)["loss_kW"]
        sg, _, _ = enumerate_single(z, Ps, Qs, base_tree)
        t2, _ = enumerate_two(z, Ps, Qs, base_tree, sg)
        print("负荷 %d%%: 基态 %.1f kW 重构 %.1f kW 降损 %.2f%%"
              % (round(lv * 100), bl, t2["loss"],
                 (bl - t2["loss"]) / bl * 100.0))