1. 先说结论:市面上电-气-热耦合代码的通病,以及我们怎么绕开的
1.1 注释敷衍只是表面,"耦合逻辑不完整"才是病根
最近在搞综合能源系统调度项目,把电-气-热耦合调度这一摊子代码翻了个底朝天。先说个让人挺无奈的现象:很多开源仓库里的实现,跑通一个固定的小算例完全没问题,但只要你换个拓扑、改个设备参数、加一条约束条件,它立刻原地爆炸。表现形态五花八门,有的直接报错退出,有的计算结果明显不合理却照样输出,最坑的是那种算完了你也不知道它算的对不对。
我翻了几十份代码之后,发现大家吐槽的"注释敷衍"其实只是表象,真正的病根是耦合逻辑不完整。怎么个不完整法?典型的三宗罪:
- 电气热三条能量流各写各的,互相之间靠几个全局变量传递数据。电网潮流是一套数组,气网是一套数组,热网又是一套数组,三者唯一的交集发生在某个"接口函数"里,传参一多就彻底懵了。
- 耦合设备的约束和效率特性写死在业务代码里。比如电转气设备的耗电系数、热电联产机组的热电比,直接写在构造函数里,换一台设备型号就得改源码重编译。
- 从没想过"可扩展"。你想加一个氢能母线、加一台热泵、改一下天然气网的压力等级,得顺着代码一路找过去,改完这头漏了那头。
这帮我们定了一个非常明确的目标:团队自己撸的这套代码,绝不能只是"能跑",必须做到能改、能扩展、能验证。后面聊的几个实现细节,全部围绕这三件事展开。
1.2 我们定的三条红线:数据结构统一、设备逻辑自治、求解器不碰业务
动手写代码之前,我们内部先立了三条红线,后面所有设计都是为了守住它们:
- 数据结构统一:电能、天然气、热能,本质上都是一条条能量流,不能在代码里被当成三种完全不相干的东西。必须有一种数据结构能把它们摊平到同一套框架里,让电气热只是"类型"上的差异,而不是"数据结构"上的差异。
- 设备逻辑自治:热电联产、电转气、热泵这类耦合设备,它们是电-气-热三网的交汇点,必须把它们的全部行为(效率、可行域、成本)封装成一个独立的、可替换的模块,绝不能把设备逻辑散落在各处调用。
- 求解器不碰业务:多能流求解器只负责解方程组,不应该知道自己在解电网还是气网。它面对的只是一组残差方程和雅可比矩阵,至于残差是潮流方程还是天然气管道方程,跟求解器无关。
这套设计最后被我们内部叫作"某多能流求解内核",代号只是个内部称呼,但这三条红线基本从头贯彻到尾。下面挑几个实现细节聊聊,每一个都是我们实际写代码时反复打磨过的地方。
2. 能量枢纽的数据结构:把三种能源摊平到一个内核里
2.1 多数代码的写法:三个互不说话的"王国"
先说说我在外面看到的常见写法。很多代码把电网、气网、热网处理成三个独立的领域:
- 电网部分:节点数组、支路数组、PV节点、平衡节点,一套潮流计算的逻辑。
- 气网部分:节点压力、管道流量、压缩机,一套气体流动计算的逻辑。
- 热网部分:热源、热负荷、供水回水温度,一套热力计算的逻辑。
三个王国各自为政,每个都有自己的数据类、自己的初始化流程、自己的迭代方法。电-气-热耦合体现在哪儿?通常是在一个顶层函数里,先算电网,把某个节点功率传给气网的压缩机;再算气网,把天然气流量传给电网的燃气轮机;最后算热网,把热负荷反馈给电网。如此循环往复。
这种写法有没有问题?在算例规模小、耦合点少的时候,其实也能用。但一旦系统有十几个耦合点,比如3台热电联产机组、2个电转气站、4台热泵同时挂在电-气-热三网上,你会发现传参顺序能把你绕晕。更严重的是,哪天想加一种新的能源形式,比如氢能,就得给这个"三国演义"再添一个国家,所有顶层函数全部要加一整套分支逻辑。
2.2 我们的做法:母线-链路统一模型
我们用的思路,是学界这些年逐渐统一的能量枢纽(Energy Hub)模型,把它在代码层面扎实落地。
核心只有两个概念:
- 母线(Bus):能源网络的公共节点。一条母线有其所属的能源类型(电/气/热),有一组状态变量(电压幅值、气压、温度等),可以连接外部网络(电网节点、气网节点、热网节点),也可以连接枢纽内部的转换设备。
- 链路(Link):连接母线之间的能量转换通道。简单说就是耦合设备在数据层面的抽象。一条链路有一个输入母线、一个输出母线、一组效率特性和一组可行域约束。
用一个简化的类关系来说明:
class EnergyBus: type: EnergyType # ELECTRIC / GAS / HEAT / HYDROGEN state_vars: np.ndarray # 状态变量(电压幅值/气压/温度等) external_network_id: int # 对应外部电网/气网/热网的节点编号 class ConverterLink: input_bus: int # 输入母线索引 output_bus: int # 输出母线索引 efficiency_model: callable # 效率/转换关系 feasible_region: FeasibleRegion # 可行域(含上下限、多边形约束) cost_model: callable # 运行成本 status: int # 启停状态、检修状态等你别看这个抽象简单,它带来的直接好处是:电气热在数据结构层面消失了。调度模型看到的,就是一张由母线和链路组成的"能量网络图"。电网、气网、热网只是这块图的不同子图,耦合点就是那些跨类型连线的链路。
到这里可能会有朋友问:三相潮流怎么办?天然气网络还有压缩机模型,温度和流量耦合怎么办?矩阵里状态变量类型不同,怎么统一求解?这些细节我后面的章节会展开说。但数据结构这一层,统一到"母线-链路"模型是完全必要的,它保证上层逻辑不用关心能源类型。
2.3 为什么这个模型好改:加氢能、加设备都是"注册"而不是"改代码"
这个模型的扩展性,是我最想强调的部分。我们用枚举类型标记能源类型,所有逻辑都建立在"类型+通用接口"之上:
- 加一种新能量(比如氢能),只需要给
EnergyType加一个枚举值,再补一套氢能母线的状态方程,不需要动电网气网热网的任何代码。 - 加一种新设备(比如电解槽、换热站),只需要写一个新的类实现
ConverterLink接口,注册进去就能被调度模型识别。 - 调整网络拓扑,修改母线之间的链路连接关系即可,数据层完全解耦。
这一点在实际项目里有多值钱?举个例子:项目做到中途,业主突然说供热管网里要加一套蓄热罐。蓄热罐本质上不是简单的转换设备,它带能量存储状态,时间维度上跨时段耦合。换在"三国演义"式的代码里,这意味着要在热网模块里塞一个复杂的状态量对象,还要改调度程序的时段循环逻辑。而在我们的框架里,蓄热罐不过是一种特殊的链路,输入输出在同一条热母线上,多一个"储能状态"的内部变量,接口层不变,调度模型甚至不需要知道它内部是这样的设备。
3. 耦合设备怎么抽象才不散装:CHP、P2G、热泵的接口设计
3.1 耦合设备的本质:换能器 + 可行域 + 成本,三件事必须绑在一起
电-气-热耦合系统里,最重要的那批设备,我们统称"耦合设备"。它们的共同特征是:消耗一种能源,产出另一种或两种能源。比如:
- 热电联产机组(CHP):天然气 → 电 + 热。效率分发电效率和热回收效率,还有一个重要的比例关系——热电比。
- 电转气设备(P2G):电 → 天然气(实际过程是电→氢→甲烷化)。整体效率通常在 40%~75% 之间,这一趟转换损失不小。
- 热泵:电 → 热。性能系数(COP)不是常数,随热源温度和供热温度变化。
很多代码把这三种设备当成三种完全独立的东西来写:CHP 在电网模块里有个函数,在热网模块里又有个函数;P2G 的耗电模型在电网侧,产气模型在气网侧,写两处;热泵干脆就是个"电热负荷转换系数"。结果就是:一台设备的完整行为被打散在少则两处、多则五处代码里。
我们的做法是:把耦合设备当成一个自洽的"换能器",把它所有的行为绑定在一个模块里。效率映射、可行域、运行成本,必须在一个地方定义,对外只暴露统一的接口。
3.2 效率映射:别用常数,用分段曲线查表
我给ConverterLink设计的效率模型,不是一个常数,而是一个分段函数映射。以 CHP 为例,它的电气效率随负载率变化明显,低负载时效率低,高负载时效率变化又趋于平缓。火电机组的效率曲线你肯定见过,CHP 的同理,只是还要把热回收效率叠加上去。
我们在代码里用分段线性函数来描述:
class PiecewiseEfficiency: breakpoints: list[float] # 负载率分界点 efficiencies: list[float] # 各段效率值 output_ratio: float # 副产品比例(CHP的热电比等) def get_efficiency(self, load_factor: float) -> float: # 二分查找负载率所在区间,线性插值得到效率 idx = bisect_left(self.breakpoints, load_factor) idx = min(max(idx, 1), len(self.breakpoints) - 1) x0, x1 = self.breakpoints[idx - 1], self.breakpoints[idx] y0, y1 = self.efficiencies[idx - 1], self.efficiencies[idx] return y0 + (y1 - y0) * (load_factor - x0) / (x1 - x0)为什么要做成分段线性而不是直接用一个拟合的多项式?两个原因:
- 分段线性在优化模型里可以直接转化为线性约束,不用引入非线性求解,计算稳定性和求解速度都好得多。
- 实际测试中,多项式拟合在边界处容易震荡,特别是低负载区,效率曲线可能跑出负值或超过100%的荒谬结果。分段线性天然没有这个问题。
同理,P2G 的效率曲线也是分段处理。市面上有些论文把 P2G 效率简单写成一个 60% 的常数,实属理想化过头。实际工程里,电解槽部分负载时的效率差异非常大,低负载下能耗反而更高。这种细节反映到调度结果上,会让"什么时候开 P2G、开多少功率"的决策完全变味。
3.3 可行域裁剪:最容易被忽略、又最致命的一块
耦合设备除了效率特性,还有个大家容易忽略的点:可行域不是简单的功率上下限。
以 CHP 为例,它的电出力和热出力不是两两独立可选的。你见过那种电热运行区间图吧?横轴是热出力、纵轴是电出力,可行域是一个凸多边形,四个角分别对应"纯凝工况""背压工况""最小凝汽流量""最大进汽量"等边界。写代码的时候,如果只设置P_min、P_max、H_min、H_max四个独立边界,那你实际上是在用一个矩形去逼近一个多边形,会在右上方多出一大块根本不存在的运行区间,调度结果自然就跑进死区里去了。
我们实现里做了一个专门的多边形可行域裁剪模块:
class FeasibleRegion: vertices: list[tuple[float, float]] # 多边形顶点列表 def is_feasible(self, p: float, h: float) -> bool: # 射线法判断点是否在多边形内 pass def project_to_region(self, p: float, h: float) -> tuple[float, float]: # 将不可行点投影回多边形的最近边界点 pass这个模块的project_to_region方法,在联立求解迭代时特别有用。牛顿法迭代到中间步,电出力、热出力组合可能跑到可行域外面,如果不做投影修正,下一步求解大概率发散。我们所有的耦合设备都强制配置可行域多边形,即便某些设备的可行域退化成矩形,也必须用四个顶点的多边形形式写,统一走同一套裁剪逻辑。
3.4 踩过的坑:效率参数散落三处的返工教训
讲一个我们真实的返工经历。第一版代码里,CHP 的效率参数是按"业务方便"散着写的:发电效率在chp.py里定义,热回收效率在heat_network.py里引用,成本系数又在economic_dispatch.py里设置。有一次我们调整一台机组的效率曲线,改了发电效率,结果发现热回收效率还是旧的,成本计算用的又是另一个值,整个调度结果乱了套。
那次返工让我们定了一个死规矩:一台设备的全部参数,集中在一个配置对象里,任何外部模块不得直接读取设备参数的内部字段,只能通过接口方法取值。
class ChpUnit: def __init__(self, config: ChpConfig): self.config = config # 效率曲线、可行域、成本系数全部在这里 def electric_output(self, gas_input: float) -> float: return gas_input * self.config.elec_efficiency.get_efficiency(gas_input) def heat_output(self, gas_input: float) -> float: return self.electric_output(gas_input) * self.config.heat_to_power_ratio def operation_cost(self, gas_input: float) -> float: return gas_input * self.config.gas_price + ...这样改完之后,换一台设备就是加载一个新的ChpConfig配置对象,代码一行不用动。效率参数、可行域多边形、成本系数全部随配置走,再也没有"改了一处漏了另一处"的问题。
4. 多能流联立求解:块状雅可比矩阵是怎么救命的
4.1 为什么不推荐顺序迭代法:耦合强了容易震荡
电-气-热多能流求解,江湖上有两派:顺序迭代法和联立求解法。
顺序迭代法的思路是:先解电网潮流,得到燃气轮机和 P2G 的耗气/产气量;带入气网方程,解气网;再解热网;然后回到电网,更新电网边界条件,循环直到收敛。这种做法实现简单,能复用三套独立的求解器,不少代码都这么干。
实际用下来,效果不算好。只要耦合度一高,比如气网压缩机耗电显著、P2G 耗电占比大,顺序迭代的收敛速度就会急剧下降,严重的直接来回震荡不收敛。如果三网里有重负荷的天然气机组和一组冬季供热的大热泵,这种边界的来回跳变会让你调试到怀疑人生。
顺带插一句,我在网上看到过一种"半联立"思路:电网和气网联立,热网在外面迭代。这种做法比全套顺序迭代好一些,但热网与电、气网的耦合如果被忽略了,整体残差精度还是会受拖累。比如冬季供热大负荷时,热泵的耗电量直接改变电网潮流边界,这个影响可不容忽视。
4.2 联立求解与块状雅可比矩阵:稀疏性是性能的核心
我们最后选了全套联立求解,也就是把电网节点电压、气网节点压力、热网供回水温度和流量,全部堆进同一个大未知量向量,构建统一残差方程组,用牛顿-拉夫逊法一起迭代。
这个方程组有多大?一个中等规模的案例,电节点 100 个(复数变量拆成实部虚部约 200 个)、气节点 50 个、热节点 80 个(供回水双变量约 160 个),加上耦合设备和外部网络接口变量,总未知量动辄 500 到 1000 个。你要天真地用稠密矩阵存储雅可比矩阵,单次求解、分解的复杂度就是 O(n³),50 个迭代步跑下来,时间和内存都很难看。
我们做对了的关键一步:利用了电-气-热雅可比矩阵天然的块状稀疏结构。
雅可比矩阵 J 的分块格局大致如下:
- 左上角是电网潮流方程对电压实部/虚部的偏导,满值的对角线密集块,但经过节点导纳矩阵稀疏性处理后比稠密好很多。
- 中间是对角块状的气网方程偏导,天然稀疏,因为每根管道的压力只关联两端节点。
- 右下是热网的温度流量方程偏导,同样是稀疏的。
- 非对角块(电网方程对气网压力、气网方程对电压)只在耦合设备处才有非零项。
换句话说,这个大矩阵其实特别"空"。我们用基于块的稀疏存储格式,把每个(母线类型 × 母线类型)的小块存成一个小矩阵,非零块单独存,零块直接跳过。求解时用稀疏 LU 分解,性能比稠密存储快了一个数量级还多。
这里有个小技巧:块稀疏的分块粒度按能源类型划分,而不是按节点划分。也就是说,电-电块是一个大方块内部有稀疏结构,电-气块则是电节点和气节点交叉的小矩阵集合。这样的分块粒度在做网络解耦时特别好用,后期如果想做并行计算,按块切分也比按节点切分自然得多。
4.3 阻尼牛顿法与初值兜底:对付发散的两道保险
联立求解最大的痛点是对初值敏感。牛顿法初值离解太远,单步修正量过大,迭代直接跳出海王星。我们的应对是两个层级的保护:
第一级是阻尼牛顿法。每次算出修正量 Δx 之后,不直接x += Δx,而是做一次线性搜索,找一个合适的步长 α,让残差范数稳步下降:
alpha = 1.0 while residual_norm(x + alpha * dx) > (1 + 1e-4 * alpha) * residual_norm(x): alpha *= 0.5 if alpha < 1e-8: raise ConvergenceError("步长搜索失败,残差无法下降") x += alpha * dx这招非常管用,能把"直接发散"变成"残差不降但也不涨"的温和状态,起码不崩。如果你调试时发现某一步残差突然跳了 10 个数量级,大概率就是没做阻尼。
第二级是物理可行域投影。每次迭代修正完状态变量后,把所有设备的出力变量投影回各自的可行域多边形里(就是前面说的project_to_region)。这样能保证中间迭代点始终在物理上勉强说得过去——气流负到离谱、温度低到绝对零度之类的荒谬状态就不会发生,求解稳定性大幅提升。
在代码里这两级保护是顺序执行的,先修正方向,再做可行域裁剪,然后进入残差重算。我们做过统计,加了这两道保险之后,典型场景的牛顿法迭代步数只增加了 10%~20%,但发散率从大约 15% 直接降到了 1% 以下。这笔账非常划算。
4.4 求解器不碰业务的落地方式
回到我们定的红线第三条:求解器不碰业务。具体到代码上,牛顿法的核心函数只接受三个东西:
def newton_solve( residual_func: callable, # 给定状态向量x,返回残差向量 jacobian_func: callable, # 给定状态向量x,返回雅可比矩阵 x0: np.ndarray, # 初值 tol: float = 1e-8, max_iter: int = 50, ) -> Solution: ...求解器完全不关心残差函数内部长什么样。是电网潮流还是气网压力方程,统统与你无关。这样做的直接好处有两个:一是可以针对不同系统复用同一个求解器,二是换收敛判据、加调试信息、做并行化,都不必碰具体的能量网络代码。
5. 代码写完了,验证才是真考验:我们是怎么自证可靠的
5.1 测试算例怎么搭:电-气-热三网的组合与数据来源
一套调度代码,如果没有经过严谨验证,跟玩具没什么区别。验证的关键问题是:用什么案例来考它?
我们最终采用的组合是:
| 网络 | 采用模型 | 规模 | 来源 |
|---|---|---|---|
| 电网 | 中压配电网典型模型 | 33 节点 | 学术论文标准算例 |
| 天然气网 | 中压输配气网典型模型 | 20 节点(含 2 座压缩机站) | 学术论文标准算例 |
| 热网 | 简化供热管网模型 | 14 节点(含 1 个热源) | 自构建并对照经典文献 |
| 耦合设备 | 2 台 CHP、1 个 P2G 站、2 台热泵 | — | 参数取自公开文献 |
这里有个细节值得分享:电网和气网直接采用公开算例,热网反而最难找。热网的公开标准算例远少于电力和天然气,因为供热行业的数据大多掌握在各自企业手里。我们的处理方式是用一个简化的 14 节点环状热网,把供回水温度、管道阻力特性系数按经典热力学教材的举例数据设置,然后对照论文中的结果验证。你如果也要做类似项目,不用纠结于必须用"官方认证"的热网算例,找一篇设计参数齐全的论文,把它的数据完整复现出来,就已经是很扎实的验证了。
5.2 我们设计的验证矩阵:不止跑通一个案例
验证矩阵我们分了三个维度:
- 基准对照:用上述组合复现论文里的经典案例,把我们的电-气-热多能流计算结果与论文标称结果对比。收敛误差做得很细,电网电压幅值误差控制在 0.001 p.u. 以内,气网压力误差控制在 0.5% 以内,热网温度误差控制在 0.1°C 以内。
- 收敛性测试:分别从全零初值、平启动带合理幅值初值、上一时刻最优解热启动三种方式启动,记录收敛迭代次数与残差下降曲线。这一步特别重要,因为实盘调度时你不太可能每次都有好初值,若是全零初值直接发散,这套代码在工程上就没法用。
- 场景压力测试:负荷波动大的冬季典型日、风电光伏出力波动大的夏季典型日、耦合设备检修停运场景,一条条过。每个场景盯着三网的关键节点状态量,看有没有越限、有没有振荡、有无物理上荒谬的结果。
5.3 我印象最深的返工:热网非线性校验没通过的教训
最后说一个让我印象最深的返工。第一版热网模型里,我为了省事,把热网管道的水头损失简化成了线性比例关系——管道流量和压降线性挂钩。这在低流量时还能凑合,但冬季热负荷大、管段流量大的时候,非线性关系完全不可忽略。结果联立求解一跑,热网温度计算还算稳,但泵耗功率明显偏小,怎么都对不上公式。
回头一查,发现我把达西-韦斯巴赫公式里的流量平方项给弄丢了。那段返工让我领悟到一件事:在综合能源系统里,跨能源的误差会层层传染。热网那点压降误差,往小了说只影响泵耗,往大了说会影响热源出力和电网侧的热泵负荷边界。所以测试时不能只盯着"温度算得对不对",还要看耦合边界上的功率/流量是否守恒。我们后来在测试模块里加了一组"耦合节点功率平衡校验",要求每个耦合设备两侧的功率偏差不超过 0.1%,才算测试通过。
验证这套流程走完之后,代码才真正有底气拿到项目里去用,换算例改拓扑也敢拍胸脯说没问题了。如果你也在搞综合能源系统调度,建议别急着堆功能,先把验证矩阵建起来,否则后面每一次改动都可能变成一次盲人摸象。
最后再分享一个小技巧。联立求解代码里一定要留一个"调试模式",把每次迭代的残差范数、最大修正量、可行域投影命中次数记录到日志文件里。别小看这个功能,排查发散问题时它能帮你省下至少一半的定位时间。我自己调试代码时,全靠这些中间输出反推哪条方程、哪个设备把牛顿迭代带偏了。一套好的综合能源调度代码,除了算法漂亮之外,可调试性同样值得花心思打磨。