需求响应下的微网综合能源系统优化:模型构建、Pyomo实现与扩展

发布时间:2026/10/14 14:48:54
需求响应下的微网综合能源系统优化:模型构建、Pyomo实现与扩展
简介一套面向社区综合能源系统优化调度的MATLAB代码基于主从博弈框架适用于微网优化、电热联合定价及多主体协同交互研究兼顾新手学习与在此基础上二次扩展。代码以综合能源系统整体收益为目标上层采用DE优化算法下层通过CPLEX求解器嵌入求解构建领导者-跟随者Stackelberg博弈模型同时计及需求响应、电价/热价以及功率/热能平衡等多重约束运行可输出全部图表。压缩包共11个m文件主函数Untitled.m负责整体流程其余子函数及遗传操作算子注释清晰整体仅13KB便于逐模块拆解学习。目前已有1805人学习下载是理解双层优化与主从博弈机制的优质参考实现尤其适合在原始数据基础上修改参数、验证不同场景下的协同优化效果。1. 考虑需求响应的微网/社区综合能源系统优化为什么说你缺的是一个能“完美运行”的底座考虑需求响应的微网、社区综合能源系统优化说穿了就是把“电价会变、负荷能挪、设备能启停”这三件事同时塞进一个优化模型里让系统在每个时间步都做出成本最小的决策。很多从业者拿到相关代码后跑通的概率并不高卡在求解器报错、图不全、注释看不懂。标题里“完美运行、注释清楚、跑出所有图、可在基础上扩展”这四点其实是这类代码真正该有的底线——它得能作为EI文章的仿真验证地基而不只是一个能看结果的脚本。这套方向适合两类人刚接触微网优化、需要一个可复现基线系统的在校研究者以及已经在做综合能源项目、想把经验调度换成优化调度的工程师。本文按“模型怎么建—代码怎么落—图怎么出—坑怎么避—文章怎么接”这条线把前因后果讲清楚。2. 从物理场景到数学表达需求响应怎么进微网优化的目标函数与约束2.1 社区综合能源系统里的“多能互补”到底互补什么先别急着看代码。做优化之前得先把社区这个尺度上的物理系统拆明白。一个典型的社区综合能源系统常见拓扑是光伏阵列接在母线上储能电池也接在母线上燃气轮机或内燃机发电供本地负荷、余热进换热机组供热电锅炉把多余的电转成热放进蓄热罐外网联络线作为电力的兜底。这个配置里有个关键观察电和热的源端不同步。光伏中午出力大但热负荷早晨最高燃气轮机联产时电出力受热负荷制约储能只能存电、蓄热罐只能存热。互补就发生在“源荷时间错配”和“不同设备的能力差异”上。把光伏大发时段的多余电能存进电池或电锅炉把燃气轮机的余热在白天热需求低时存进蓄热罐晚间热负荷起来再放。需求响应在此基础上再加一个自由度热负荷里有空调、热水这类可平移负荷电价高或机组出力紧张时可以往后挪。建模时最常见的简化是只做“电-热”双载体气网不参与优化只作为设备输入的边界条件。别贪多先把双载体的调度问题做透后面扩到冷、气都来得及。同时在社区尺度、小时级时间分辨率下我一般会把设备效率设成常数、忽略启停时间成本这套假设在EI文章初稿里完全站得住审稿人要求精细化了再往里加非线性效率曲线。设备关键参数常见取值区间说明光伏装机容量 / 预测出力300–1500 kW按实测辐照折算代码里用时间序列储能容量 / 最大充放功率200–1000 kWh / 50–250 kWSOC范围0.1–0.9效率0.9–0.98燃气轮机电功率上下限 / 发电效率100–500 kW / 0.3–0.4余热按热电比折算进热平衡电锅炉电转热效率0.9–0.98热惰性大可配合蓄热罐使用2.2 需求响应建模价格型与激励型怎么写成约束需求响应在模型里不是某个独立模块而是同时渗透在目标函数和约束里。价格型需求响应最常见它的本质是让用户侧负荷对电价做出响应。工程做法有两种一种是可平移负荷把某一类负荷设一个时间窗窗口内必须完成指定电量代码里用一个0-1变量标记“是否在t时刻启动”再配一个“启动一次且仅一次”的约束另一种是价格弹性系数给一个常数把负荷和电价挂钩这种做起来简单但结果容易失真因为弹性系数在一个社区尺度的系统里很难标定。# 可平移负荷建模窗口 [8, 16] 内启动一次持续 dur2 小时 m.y_start pyo.Var(m.T, domainpyo.Binary) def start_once_rule(m): # 窗口内累计启动次数恰好为1保证“挪一次”而不是“挪多次” return sum(m.y_start[k] for k in range(8, 17)) 1 m.start_once pyo.Constraint(rulestart_once_rule) def shift_load_rule(m, t): # 平移后t时刻的额外负荷 启动标记 * 单时段功率 # 这里用固定功率200kW持续2小时窗口外为0 if t 8 or t 17: return m.y_start[t] * 200 0 return pyo.Constraint.Skip m.shift_load pyo.Constraint(m.T, ruleshift_load_rule)激励型需求响应走的是合同逻辑用户和运营商提前约定在某几个高峰时段削减一定负荷运营商付补偿费。建模时目标函数加一项“削减补偿单价 × 削减量”约束里加“削减量不超过合同量”和“削减后负荷不低于安全下限”。这里有个排错高发点补偿单价如果低于边际供电成本优化器根本不会触发削减响应效果就是0。后面避坑章节会专门说。2.3 目标函数与约束条件的取舍为什么用混合整数线性规划目标函数把“钱”作为统一度量外网购电费用、燃气费用、设备启停费用、需求响应补偿费用减去需求响应带来的收益再减去可能的售电收益。约束条件有四类设备出力上下限、爬坡约束、储能SOC递推、母线电功率平衡和热功率平衡。如果做到和EI文章匹配的精度还应该加上启停逻辑和最小运行时间这类整数约束。为什么这类问题适合用混合整数线性规划MILP而不是直接用非线性规划两个现实原因。第一设备启停、可平移负荷的“启动判断”天然是0-1变量非线性规划里这些变量没法处理第二MILP有成熟的全局最优求解器而非线性规划很容易卡在局部最优。代价是需要做线性化光伏出力和负荷按时间步离散取值燃气轮机的效率用分段线性近似储能充放电效率拆成两个互斥的非负变量并用二进制变量防止同时充放。把物理量映射到数学符号时我习惯先画一张表格把每个量化成一个变量物理对象变量类型含义单位储能SOC连续[0,1]荷电状态%燃气轮机出力连续 0-1出力值与启停标记kW可平移负荷启动0-1t时段是否启动—购电/售电连续联络线功率符号区分流向kW削减量连续激励型需求响应削减功率kW这个映射做完模型建模就不容易漏约束。另一个常见线性化技巧是储能“不能同时充放电”写成 p_ch[t] p_dis[t] C * z[t]其中z是0-1变量C是一个足够大的数这样求解器会强制充放互斥避免“既充电又放电”这种能量空转的假解。3. 把模型敲成代码求解器选型、数据结构与核心模块拆解3.1 代码骨架数据文件、模型文件、求解文件分开写拿到这类标题的代码第一步不是跑而是看目录。如果所有东西怼在一个main.py里后面扩展就是灾难。我拿到代码的第一件事是把“数据、模型、求解、绘图”四层分开。数据层的职责是读CSV把时间序列整理成字典模型层只负责声明变量和约束求解层调用求解器并取回结果绘图层从结果对象里抽序列画图。用Python做这类优化常见的建模框架是Pyomo求解器先用开源的CBC或GLPK跑通再去换商用求解器。目录结构通常长这样microgrid_dr/ ├── data/ │ ├── load.csv # 电负荷时间列功率列 │ ├── heat_load.csv # 热负荷 │ ├── pv.csv # 光伏预测出力 │ └── tariff.csv # 分时电价与需求响应补偿参数 ├── model/ │ ├── __init__.py │ ├── network.py # 母线与平衡约束 │ ├── storage.py # 储能与蓄热罐模型 │ ├── chp.py # 燃气轮机联产模型 │ └── demand_response.py# 可平移负荷与削减约束 ├── utils/ │ └── plot_results.py └── run.py # 主入口读数据→建模型→求解→出图我一般会坚持一个原则模型文件里不出现任何具体数字所有数值从数据层读入。这样换场景只需要改CSV不需要改模型代码。很多人扩展不动就是因为模型文件里写死了参数储能容量直接写500换一个社区储能变成800就要在模型里翻找替换。run.py的主流程也很固定import pandas as pd import pyomo.environ as pyo data { T: 24, dt: 1.0, load: pd.read_csv(data/load.csv)[kw].tolist(), pv: pd.read_csv(data/pv.csv)[kw].tolist(), price_buy: pd.read_csv(data/tariff.csv)[buy].tolist(), ... } model build_model(data) # 在model/里定义 solver pyo.SolverFactory(cbc) result solver.solve(model, teeFalse) if result.solver.termination_condition pyo.TerminationCondition.optimal: # 把变量从模型里抽出来整理成dict传给绘图层 plot_dispatch(result_dict, data)逻辑说明SolverFactory里的“cbc”是开源求解器名称第一次使用前需要确认环境里已经安装termination_condition的判断是必须的不能只靠有没有报错来判断运行成功。参数说明teeFalse表示不打印求解过程排错时改成True能看分支切割日志。3.2 核心参数与注释规范让别人和三个月后的自己看得懂注释要清楚这个要求听着简单做起来极难。真正有用的注释不是“定义变量x”而是“定义储能t时刻SOC范围0-11代表满电”。单位写清楚约束注释里写物理含义。我见过不少翻车现场变量命名用a、b、c函数关系完全对不上排查到半夜才发现是约束方向写反了。# 差注释变量定义不清 m.x pyo.Var(m.T, domainpyo.NonNegativeReals) # 好注释变量、单位、物理边界一次说清 # 储能t时刻荷电状态0-11满电初值0.5终值允许0.3-0.9 m.soc pyo.Var(m.T, domainpyo.NonNegativeReals, bounds(0, 1))参数表建议在数据文件头部就放一份清单包括时间步长、调度周期、光伏装机、储能容量与初始SOC、燃气轮机功率上下限、电转热效率、需求响应补偿单价和可平移负荷窗口。每个参数都要有默认值并且注释里标注来源或假设params { T: 24, # 调度周期单位 h dt: 1.0, # 时间步长单位 h pv_cap: 800.0, # 光伏装机容量单位 kW ess_cap: 400.0, # 储能容量单位 kWh ess_soc_init: 0.5, # 初始SOC0.5表示半电 chp_p_min: 100.0, # 燃气轮机最小出力单位 kW chp_p_max: 500.0, # 燃气轮机最大出力单位 kW chp_eta_e: 0.35, # 发电效率 eb_eta: 0.95, # 电锅炉电转热效率 dr_unit_price: 0.8, # 激励型需求响应补偿单价元/kWh }这套参数的意义在于当你要验证某个策略的敏感性时只需要改这里的值然后跑一批对比算例。回到做EI文章的场景审稿人会追问“参数来自哪里”有这套参数清单每个数据都能交代来源。3.3 最小可运行脚本先跑通24小时的最小系统先别贪大。我一般会先把系统简化成“光伏储能外网购电”三件套跑通一个24小时的调度再往上加燃气轮机和热网。最小版的核心模型长这样import pyomo.environ as pyo def build_model(data): m pyo.ConcreteModel() m.T pyo.RangeSet(0, data[T] - 1) # 购电功率、售电功率、储能充放功率、SOC m.p_buy pyo.Var(m.T, domainpyo.NonNegativeReals, bounds(0, 500)) m.p_sell pyo.Var(m.T, domainpyo.NonNegativeReals, bounds(0, 300)) m.p_ch pyo.Var(m.T, domainpyo.NonNegativeReals, bounds(0, 200)) m.p_dis pyo.Var(m.T, domainpyo.NonNegativeReals, bounds(0, 200)) m.soc pyo.Var(m.T, domainpyo.NonNegativeReals, bounds(0, 1)) # 目标购电费用 - 售电收益 def obj_rule(m): return sum(data[price_buy][t] * m.p_buy[t] - data[price_sell][t] * m.p_sell[t] for t in m.T) m.obj pyo.Objective(ruleobj_rule, sensepyo.minimize) # 电功率平衡光伏 购电 放电 负荷 充电 售电 def balance_rule(m, t): return (data[pv][t] m.p_buy[t] m.p_dis[t] data[load][t] m.p_ch[t] m.p_sell[t]) m.balance pyo.Constraint(m.T, rulebalance_rule) # SOC递推soc[t] soc[t-1] (充电效率*充电 - 放电/放电效率) * dt / 容量 def soc_rule(m, t): if t 0: return m.soc[t] data[ess_soc_init] return m.soc[t] m.soc[t-1] ( m.p_ch[t] * 0.95 - m.p_dis[t] / 0.95) * data[dt] / data[ess_cap] m.soc_cons pyo.Constraint(m.T, rulesoc_rule) return m逻辑说明平衡约束里的“售电功率”作用很重要没有它光伏大发时剩余电力没有去处模型可能直接报不可行。SOC递推式里充放电效率位置不同充电时效率乘在输入侧放电时效率除在输出侧这是锂电池效率定义的常见坑方向写反会导致后续天数的SOC越来越虚。参数说明购电上限500和售电上限300是对联络线的简化假设实际应该查变压器的额定容量SOC递推中的0.95是充放电效率细化时应该拆成充、放两个参数。跑完这个最小版确认结果合理后再把燃气轮机的启停变量加进去能让你把每个模块单独验证清楚。4. 跑出所有图数据可视化与结果输出的标准套路4.1 输出什么图出力曲线、SOC曲线、能量平衡图“跑出所有图”这个要求其实说的是结果可视化要能覆盖审稿人的基本审视清单。一张完整的调度结果图集通常包含五类图每类图都有自己的判读重点图名内容主要看什么设备出力曲线光伏、燃气轮机、储能充放功率负荷跟随关系高价时段是否减少购电SOC曲线储能与蓄热罐状态储能是否合理循环有没有长期满电或放空电价-购电联动图电价与购电量双轴图高价时段购电量是否明显下降平衡堆叠图各电源出力叠加与负荷对比平衡约束是否满足弃光在哪里发生需求响应对比图原始负荷 vs 响应后负荷响应量大小整体削峰填谷效果我做图的习惯是先画“全量图”再挑“文章图”。全量图就是上面五类全输出每张对应一个PNG文件文件名带场景名和时间标签文章图是在全量图基础上裁剪出来的只保留论文里需要的那几张。这样做的好处是省得每次改参数后对着屏幕数坐标轴。4.2 matplotlib绘图模板多子图存储与中文乱码处理绘图层的核心模板通常写成这样一个函数。模板里有两个细节值得注意一是中文字体必须显式注册不能依赖运行机器上“恰好装了某个字体”二是保存图片的dpi直接影响期刊清晰度。import matplotlib.pyplot as plt import matplotlib.font_manager as fm import numpy as np # 把字体文件放进项目 assets/fonts 目录路径写死不依赖系统字体 font_path assets/fonts/SimHei.ttf fm.fontManager.addfont(font_path) plt.rcParams[font.sans-serif] [fm.FontProperties(fnamefont_path).get_name()] plt.rcParams[axes.unicode_minus] False def plot_dispatch(result, data, save_pathresults/): t np.arange(data[T]) fig, axes plt.subplots(3, 1, figsize(10, 12), sharexTrue) # 子图1电功率平衡堆叠图 axes[0].stackplot(t, data[pv], result[chp], result[dis], labels[光伏, 燃气轮机, 储能放电], alpha0.7) axes[0].plot(t, data[load], k--, label负荷(响应后), linewidth1.5) axes[0].set_ylabel(功率/kW) axes[0].legend(locupper right) axes[0].grid(alpha0.3) # 子图2储能SOC axes[1].plot(t, result[soc] * 100, labelSOC, colorgreen) axes[1].set_ylabel(SOC/%) axes[1].set_ylim(0, 110) axes[1].legend() axes[1].grid(alpha0.3) # 子图3需求响应前后负荷对比 axes[2].plot(t, data[load_orig], label原始负荷, colorgray) axes[2].plot(t, data[load], label响应后负荷, colorred) axes[2].fill_between(t, data[load_orig], data[load], alpha0.2, label响应量) axes[2].set_xlabel(时间/h) axes[2].set_ylabel(负荷/kW) axes[2].legend() axes[2].grid(alpha0.3) fig.align_ylabels() fig.savefig(f{save_path}dispatch.png, dpi300, bbox_inchestight)逻辑说明stackplot适合画能量平衡堆叠图因为各电源出力天然是累加关系储能SOC画成百分比更直观所以要乘以100。子图3里fill_between把两条负荷曲线之间的区域填色这个色块面积就是需求响应带来的电量转移效果文章里放这张图信息量很足。参数说明dpi300是期刊位图的基本要求低于150会被审稿人嫌弃figsize的长宽比要按照期刊单栏或双栏宽度来设。4.3 结果异常时先看哪几个量跑出来的图不是每次都能直接用。结果不合理时先看三个量目标函数值、平衡约束残差、SOC终值。目标函数值如果是一个超出直觉的巨大数字先怀疑数据单位kW和kWh混用是重灾区。平衡约束残差可以把求解后各功率直接带入约束式验证如果残差不为零多半是模型声明约束时索引写错了。SOC终值如果和初始值差得离谱又没有任何充放电行为说明效率参数方向反了储能每步都在损耗能量系统会倾向于不用它。另外我习惯看求解器返回的状态字段而不是只看有没有图输出。Pyomo求解后返回的结果对象里有Solver Status和Termination Condition这两个字段才是运行是否真正成功的标准。图出来不代表解收敛很多绘图脚本在变量全是0时也能画出图——曲线贴着零轴跑那说明模型有问题再好看的图也不能用。5. 完美运行的避坑清单从求解器报错到结果不合理标题里的“完美运行”是结果过程里全是坑。这一节列五条我反复踩过的记录按“现象—原因—解决”写清楚。5.1 现象Infeasible Problem模型告诉你无解现象求解器返回infeasible目标函数没有值图一张都出不来。这个报错在微网优化里出现频率极高。原因排查顺序只有一套先把可平移负荷相关约束注释掉重跑一遍。如果解除后模型可解问题大概率在需求响应的窗口约束设置上——比如把“一天内完成500kWh的平移负荷”错写成了“每个时段都必须完成500kWh”这会让系统在负荷低谷时段也强制启动设备超出物理能力。另一个常见原因是储能初始SOC约束与最终SOC约束同时设置且数值冲突比如要求初始0.5、终值0.5但中间没有足够的充放电空间去实现这个循环。解决分两步。先把所有“两端强制”的约束改成单向比如SOC终值从等于改成大于等于0.4给优化器留裕度。再检查平衡约束里电源侧和负荷侧有没有遗漏项尤其售电功率、弃光变量。我见过大量infeasible是漏了弃光变量光伏出力固定负荷又低储能充满购电不能为负此时如果没有“弃光”这个松弛变量平衡约束就无解。正确做法是在平衡式里加一个非负的弃电量目标函数给它一个极小的惩罚系数这样模型永远不会因为“电太多”而崩溃。5.2 现象需求响应补偿单价设了但响应量始终为0现象目标函数里加了补偿项约束里也加了削减上限但优化结果里可平移负荷一个没挪削减量全为0对比图出不来效果。原因补偿单价低于边际购电成本时优化器会认为“买电给用户”更划算。需求响应要在目标函数里形成真正的trade-off补偿单价必须高于削减时段对应的边际供电价格否则逻辑上永远不会触发削减。解决先把电价曲线里峰值时段的价格拉出来再把补偿单价设定为峰值电价的1.2到1.5倍。另外一个隐藏原因是可平移负荷的时间窗设得太窄。比如窗口是10:00-11:00而该时段本身就是负荷高峰挪到哪里都避不开高价电自然没有响应动机。把窗口放宽到8:00-16:00再去看响应量是否出现。这也是最容易被忽略的“参数设计”问题不是代码bug。5.3 现象同一套参数两次运行结果不一样现象代码没改任何东西连续跑两次目标函数值和出力曲线不完全一致。原因这类优化模型的非唯一解情况很多。可平移负荷在多个时间段都有相同的边际成本时平移结果等价但出力曲线不同或者求解器默认的MIP gap和随机种子导致分支顺序不同最终停在不同的整数可行解上。这算是个玄学问题但根源是确定的。解决如果结果只有微小差异通常是求解容差问题把MIP gap从默认的0.05收紧到0.01结果会稳定下来如果是完全不同的调度策略但目标函数值几乎一样那就要在目标函数里加一个小的二次正则项比如对所有变量加一个0.0001系数的平方项让优化器在多个最优解里挑一个最“平稳”的。这个技巧做敏感性分析时特别重要否则你会说不清结果差异到底来自参数变化还是求解器随机性。5.4 现象图能出来但中文全部变方块保存的PNG和屏幕显示不一致现象本地跑出来中文正常换一台机器跑就变方块或者屏幕上正常但保存的图里全是豆腐块。原因matplotlib的字体选择依赖系统安装的字体库SimHei、微软雅黑在Linux服务器上不一定存在保存图片时后端重新检索字体和屏幕渲染走的不是同一套逻辑所以出现“看着正常存出来乱码”。解决不要依赖系统字体把字体文件放进项目目录的assets/fonts下面用font_manager显式注册。代码里只需要多加几行让字体路径写死、不依赖环境。这样换机器、丢到服务器上跑结果都是稳定的。中文字体文件虽然体积偏大但这点代价和“审稿人看到乱码图”的尴尬比起来不值一提。5.5 现象往模型里加储能或加微网节点后求解时间从几秒涨到几十分钟现象基础24小时模型秒解扩容到3个微网或把时间步从24改成168之后求解时间指数上升甚至内存溢出。原因MILP的求解时间和整数变量数量强相关。储能同时充放互斥逻辑要加0-1变量3个微网就是3倍可平移负荷的整数变量再叠上去问题规模就失控了。解决先用24小时、单微网验证逻辑再逐步扩大。如果时间步必须加大优先检查有没有“跨时间步”的约束比如储能SOC递推和可平移负荷的累计电量约束这些约束会破坏系数矩阵的稀疏性直接拉升求解时间。另一个有效手段是把对称的等价约束合并多个微网如果参数完全相同可以显式加上对称性破缺约束让求解器不必探索等价分支。这个优化做完求解时间常常能降一个数量级。6. 从复现到EI文章扩展思路与论文仿真衔接6.1 扩展方向多微网互联、碳约束、不确定性这套代码真正值钱的部分在“可在基础上扩展”这六个字。我在实际使用中顺着三个方向扩展过。方向一是多微网互联把单微网的母线约束扩展为节点间的功率交换约束在目标函数里加一个交易收益项代码量不大但从“单微网优化”到“多微网协同”是质变级别的创新点。方向二是碳约束在燃气轮机和购电项上累加碳排放量再加一个碳上限约束或者直接给碳排放定个影子价格进入目标函数。方向三是不确定性把光伏出力和负荷从“已知曲线”换成“场景集合”用两阶段或鲁棒优化的形式处理这个扩展工作量最大但也是EI文章里最容易被认可的深度。6.2 把代码结果转化成论文图表跑出图之后还得做一件事把仿真结果整理成论文能用的图表。我通常会再做一层后处理把结果数据导出成CSV再用单独的绘图脚本做论文图。这样论文图的样式控制和仿真代码解耦改字号、改颜色、换图例位置都不用重跑仿真。具体来说我习惯在论文里用“原始负荷与响应后负荷对比图”作为需求响应效果的核心证据配一张“不同补偿单价下的总成本对比柱状图”做敏感性分析。横坐标统一用时间或参数值纵坐标统一用功率或成本单位都标在轴标签里图例放图内右上角而不是图外。还有一个细节图里不要直接用代码里的英文变量名论文图里写的是“SOC%”不是“ess_soc”写的是“燃气轮机出力/kW”不是“chp_p”。很多人在代码里跑通了却栽在最后一步图导出去全是代码命名审稿人一眼看出是仿真截图印象分会打折扣。6.3 我的习惯与收尾最后分享一个我自己的习惯。每次拿到这类优化代码我第一件事不是跑主程序而是先把时间步改成8小时、把设备数量减半跑一个微型算例确认整套流程能走通再恢复原参数。这个“先缩再放”的做法帮我避开了很多深坑。有一次直接在168小时步长上跑等了四十分钟还没出结果后来才发现是需求响应约束写成了所有时间步的耦合约束复杂度暴涨改成微型算例后几秒就暴露了问题。从那之后我给自己立了个规矩任何优化代码先在小规模下跑通、跑稳、跑出图再放大。这套考虑需求响应微网与社区综合能源优化的方案一旦底座稳了后面的扩展和论文衔接就是顺着骨架长肉的事。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取