简介:新能源制氢系统多时间尺度优化调度策略研究复现资源,以孤岛与并网两类场景为主线,系统讲解日前改进雷达图优化、日内MPC功率跟踪,以及价格型、替代型、激励型电-氢综合需求响应模型,适合科研人员、工程师与技术专家用于算法实现、参数整定和工程方案评估。资源为单个PDF文件,大小792KB,内含完整的Python建模代码、参数注释和运行说明,风力发电、光伏发电、电池储能及电解槽集群等关键模型均给出可运行实现,结构与注释便于直接研读和二次开发。已有127人学习下载。通过该PDF可掌握多时间尺度调度框架的设计思路、需求响应机制与MPC参数整定方法,理解不同场景下提升新能源消纳率、降低运行成本、平抑源荷功率波动的实现路径,为新型电力系统氢能调度研究提供实用参考。
1. 新能源制氢系统多时间尺度优化调度:它解决的不只是“今天发多少电”
新能源制氢系统的多时间尺度优化调度,本质上是在做一件工程界经常头疼的事情:把“秒级响应的电池、分钟级爬坡的电解槽、小时级才能调整的储氢和购电计划”放在同一个目标函数下面协同工作。风电光伏的预测误差会随着预测跨度拉长到 20% 以上,单靠一个 24 小时日前计划根本压不住实际偏差;可如果每一分钟就全局重算一次,又会被预测噪声和二进制变量拖到求解困难。常见做法是把调度拆成日前、日内、实时三层,各管各的决策粒度,再用滚动更新把三层串起来。这篇文章就按这个方向,从模型、代码到踩坑点,把一套能落地的最小实现讲清楚,适合正在做风光氢储项目前期评估、准备写调度模块,或者已经跑过单一天优化却不敢直接上现场的工程师。
2. 日前、日内、实时三层怎么分工:时间尺度背后的物理与经济逻辑
2.1 时间尺度怎么拆:日前、日内、实时各承担什么任务
多时间尺度调度不是把同一个模型缩小窗口重算三遍,而是每一层解决的问题不同、决策变量不同、颗粒度也不同。
最常见的划分是:
- 日前调度(Day-ahead):以 1 小时为步长,覆盖 24 小时。任务是定下电解槽的开停机计划、储氢罐的充放节奏、储能电池的整体电量轨迹,以及参与市场交易的购电曲线。这个尺度对应的是电力市场日前出清窗口,也是预测误差最大的时段,所以要留足裕量。
- 日内滚动(Intra-day):以 15 分钟为步长,覆盖未来 4 到 6 小时。随着风、光、负荷的短时预测更新,重新优化一次功率分配,核心是把日前定下的设备状态保持住,只对功率指令做修正,而不是推翻重来。
- 实时调整(Real-time):以 5 分钟或 1 分钟为周期,输入实际测量值,做偏差校正。此时不再重新解大规模优化,而是用规则或简化模型把电解槽功率、电池充放电指令调回安全区间。
三层的时间常数差异很大。锂电可以对秒级波动进行响应,电解槽的功率爬坡通常按分钟计,而储氢罐和电解槽启停则要按小时看。把这些设备塞进同一个优化问题时,如果不按尺度分开,二进制启停变量会跟连续功率变量纠缠在一起,模型规模和求解时间都会失控。
| 层级 | 时间尺度 | 步长 | 主要决策内容 | 典型约束 |
|---|---|---|---|---|
| 日前 | 24h | 1h | 启停、产氢目标、购电曲线 | 储氢容量、最小运行时间 |
| 日内 | 4-6h | 15min | 功率分配修正 | 爬坡率、SOC边界 |
| 实时 | 15min-1h | 1-5min | 偏差校正、频率支撑 | 功率限幅、设备安全 |
2.2 风光氢储协同的物理基线怎么定:设备时间常数差异是核心
做协同调度之前,先要承认一件事:风光氢储里没有一台设备是“想怎么用就怎么用”的。
风电和光伏是波动性电源。光伏在午间快速上升、傍晚断崖式下跌,风电则可能在一小时内爬坡或掉坡几百MW。对他们的建模,关键是预测曲线和实际可用功率上限。调度模型里通常把它们作为不可控的上界,允许弃电,但弃电要有成本惩罚。
电解槽是最难伺候的设备。碱性电解槽的最小负载率一般在 20% 到 40% 之间,低于最小负载就得停机;热备状态还要消耗少量的待机功率。频繁启停会加速隔膜老化和密封件损耗。所以在日前层必须引入二进制变量记录启停状态,并加最小开机/停机时间约束或启停成本,否则优化器会为了追每一度便宜的电而让电解槽反复开停。
储能电池与储氢罐的互补逻辑不一样。电池能量密度小、功率密度大,适合吸收秒分钟级波动;储氢罐容量大、但能量转换要走电解-压缩这条路,响应慢,适合做跨小时的搬移。同步调度里要各自建立SOC递推方程,并给末端SOC设置目标或约束,否则滚动优化会把未来电量“用光”。
2.3 目标函数不只是电价:弃电、储损、氢收益都要放进公式
很多人第一版目标函数只写“购电成本最小”,结果优化器会倾向于全部买电制氢,把风光弃掉——因为弃电没有惩罚。这个方向是错的。
我常用的目标函数结构是:
最小化总运行成本 = 从电网购电成本 + 弃风弃光惩罚 + 设备启停成本 + 运维成本 - 制氢销售收入 - 需求响应补偿
每一项都要乘以对应时间步长,换算成统一的元/MWh 或元/kg 基准。氢价不是固定常数,在日前模型里一般用预测的长期协议价或现货价;需求响应补偿则来自电网侧激励信号。
目标函数里的权重设置会直接影响结果。弃电惩罚如果设得太低,优化器会倾向让新能源闲置;设得太高,则会让电解槽为了多利用 10 分钟的风光而频繁调整功率,影响设备寿命。常见做法是让弃电惩罚高于最低电价、低于电解槽单位制氢成本,具体值要靠敏感性分析去扫。
3. 最小可复现的调度内核:以 24 小时日前 MILP 入门的建模与代码
3.1 建模前置:预测数据与设备参数怎么定义
写调度代码前,先把数据契约定清楚。我们假设有一份 CSV 文件,包含时间、预测风速出力、预测光伏出力、负荷和电价。时间分辨率统一成 1 小时。读取之后先做清洗和重采样。
import pandas as pd import numpy as np # 常见的预测数据导出格式,字段分别是: # time, wind_mw, pv_mw, base_load_mw, price_mwh df = pd.read_csv("forecast_data.csv", parse_dates=["time"]) df = df.set_index("time").resample("1h").mean().interpolate() wind = df["wind_mw"].fillna(0).clip(lower=0) pv = df["pv_mw"].fillna(0).clip(lower=0) load = df["base_load_mw"].fillna(0) # 如果其中一列是 Next 价格,直接读入,不要在优化内部再预测 price = df["price_mwh"].fillna(0)逻辑说明:预测数据的时间索引必须连续,缺失段用插值补上;如果中间出现两个小时空档,优化模型里的功率平衡方程会错位。价格列直接从预测接口或市场公告里读,优化模型不再叠加预测模型,避免黑匣子套黑匣子。
设备参数一般集中放在一个字典里,方便在不同算例之间切换。需要包含的参数有:电解槽额定功率与最小功率、爬坡限制、能耗转换系数、储能电池容量与SOC边界、储氢罐上限、氢出售价格和弃电惩罚单价。
params = { "el_min": 0.3, # 电解槽最小功率,MW "el_max": 2.0, # 电解槽额定功率,MW "el_ramp": 0.5, # 电解槽爬坡速率,MW/h "h2_coef": 22.5, # 每MWh电对应的制氢量,kg/MWh "h2_price": 28.0, # 氢气售价,元/kg "h2_tank_max": 500.0, # 储氢罐容量上限,kg "batt_cap": 5.0, # 电池容量,MWh "soc_min": 0.1, # 电池SOC下限 "soc_max": 0.9, # 电池SOC上限 "batt_ch_max": 1.5, # 最大充电功率,MW "batt_dis_max": 1.5, # 最大放电功率,MW "ch_eff": 0.95, # 充电效率 "dis_eff": 0.95, # 放电效率 "curtail_penalty": 300.0, # 弃风弃光惩罚,元/MWh "base_load": 0.2, # 厂房固定负荷,MW }参数说明:h2_coef 由电解槽效率和氢气热值换算得到,常见碱性电解槽效率约 70% 以上,对应每 MWh 直流电产氢 20 到 25 kg 左右。这个系数是整个调度结果质量的命门,最好用现场 24 小时累计产氢量标定,而不是看手册值。
3.2 日前调度 MILP:核心模型与可运行代码
这一步的目标是在给定 24 小时预测数据下,求出次日各设备的运行计划。我通常用 PuLP 写混合整数线性规划,因为模型规模不大、免费求解器足够用,而且代码比直接调 Gurobi 更直观。
import pulp def day_ahead_schedule(wind, pv, load, price, P): T = len(wind) prob = pulp.LpProblem("day_ahead_h2", pulp.LpMinimize) # 连续变量 P_buy = [pulp.LpVariable(f"buy_{t}", lowBound=0) for t in range(T)] P_pvu = [pulp.LpVariable(f"pvu_{t}", lowBound=0) for t in range(T)] P_wu = [pulp.LpVariable(f"wu_{t}", lowBound=0) for t in range(T)] P_el = [pulp.LpVariable(f"el_{t}", lowBound=0) for t in range(T)] P_ch = [pulp.LpVariable(f"ch_{t}", lowBound=0) for t in range(T)] P_dis = [pulp.LpVariable(f"dis_{t}", lowBound=0) for t in range(T)] SOC = [pulp.LpVariable(f"soc_{t}", lowBound=P["soc_min"], upBound=P["soc_max"]) for t in range(T+1)] H2 = [pulp.LpVariable(f"h2_{t}", lowBound=0, upBound=P["h2_tank_max"]) for t in range(T+1)] H2p = [pulp.LpVariable(f"h2p_{t}", lowBound=0) for t in range(T)] # 二进制变量:电解槽开停机 u_el = [pulp.LpVariable(f"u_el_{t}", cat="Binary") for t in range(T)] # 目标函数:购电成本 + 弃电惩罚 - 售氢收入 prob += ( pulp.lpSum(price[t] * P_buy[t] for t in range(T)) + P["curtail_penalty"] * pulp.lpSum((pv[t] - P_pvu[t]) + (wind[t] - P_wu[t]) for t in range(T)) - P["h2_price"] * pulp.lpSum(H2p[t] for t in range(T)) ) for t in range(T): # 功率平衡 prob += P_buy[t] + P_pvu[t] + P_wu[t] + P_dis[t] - P_ch[t] == P_el[t] + P["base_load"] # 新能源出力上限 prob += P_pvu[t] <= pv[t] prob += P_wu[t] <= wind[t] # 电解槽运行范围与爬坡 prob += P_el[t] >= P["el_min"] * u_el[t] prob += P_el[t] <= P["el_max"] * u_el[t] if t > 0: prob += P_el[t] - P_el[t-1] <= P["el_ramp"] prob += P_el[t-1] - P_el[t] <= P["el_ramp"] # 制氢量与储氢平衡 prob += H2p[t] == P["h2_coef"] * P_el[t] prob += H2[t+1] == H2[t] + H2p[t] - 0.0 # 假设无外送消耗 # 电池SOC递推 prob += SOC[t+1] == SOC[t] + P["ch_eff"] * P_ch[t] - P_dis[t] / P["dis_eff"] prob += P_ch[t] <= P["batt_ch_max"] prob += P_dis[t] <= P["batt_dis_max"] # 终端状态约束:保证日末储氢和SOC不低于初始值 prob += H2[T] >= H2[0] prob += SOC[T] >= SOC[0] prob.solve(pulp.PULP_CBC_CMD(msg=False, gapRel=0.01)) print("求解状态:", pulp.LpStatus[prob.status]) result = { "P_buy": [pulp.value(x) for x in P_buy], "P_el": [pulp.value(x) for x in P_el], "SO C": None, "H2": None, } return result逻辑说明:功率平衡方程把电网购电、新能源消纳、电池充放电和电解槽功率放在同一边。电池充放电效率都乘到递推式里,避免“充 1MW 放 1MW”这种无损循环被优化器白赚。氢方程里假设没有外送消耗,实际项目如果有加氢站直供或燃料电池回电,要再加一个变量。
参数说明:gapRel=0.01表示允许 1% 的相对最优间隙,对工程调度够了。如果问题规模到数百小时,可以把 gapRel 放到 0.05,求解时间从几分钟降到几十秒。SOC 递推式中的充放电功率单独限幅,是为了防止电池同时充电和放电——虽然没有显式互斥变量,但因为充放电效率都小于 1,目标函数天然不会同时启用。
3.3 日内滚动窗口:固定启机状态、重新求解
日内滚动不是把 T=24 改成 T=6 就完事。常见做法是保留日前已经定下的电解槽启停状态,只把连续功率变量放开,否则日内层看到新预测后可能频繁改启停,设备受不了。
def rolling_update(day_ahead_plan, wind_now, pv_now, price_now, P, window=6): T = window prob = pulp.LpProblem("rolling_h2", pulp.LpMinimize) P_buy = [pulp.LpVariable(f"buy_{t}", lowBound=0) for t in range(T)] P_el = [pulp.LpVariable(f"el_{t}", lowBound=0, upBound=P["el_max"]) for t in range(T)] P_ch = [pulp.LpVariable(f"ch_{t}", lowBound=0, upBound=P["batt_ch_max"]) for t in range(T)] P_dis = [pulp.LpVariable(f"dis_{t}", lowBound=0, upBound=P["batt_dis_max"]) for t in range(T)] SOC = [pulp.LpVariable(f"soc_{t}", lowBound=P["soc_min"], upBound=P["soc_max"]) for t in range(T+1)] # 用日前计划的启停决策固定当前窗口内的开关状态 fixed_on = day_ahead_plan["u_el"][:T] for t in range(T): if fixed_on[t] == 0: prob += P_el[t] == 0 else: prob += P_el[t] >= P["el_min"] prob += P_buy[t] + wind_now[t] + pv_now[t] + P_dis[t] - P_ch[t] == P_el[t] + P["base_load"] # 末端SOC软约束 prob += SOC[T] >= SOC[0] - 0.05 prob.solve(pulp.PULP_CBC_CMD(msg=False))逻辑说明:日前计划里的 u_el 是一个 24 小时数组,滚动窗口只取前 6 个值作为约束。这样日内层只能微调功率大小,不能频繁启停设备。末端 SOC 用软约束给优化器留了一点偏差空间,避免因为预测突变导致模型无解。
参数说明:窗口长度 6 小时是我在多数项目里偏好的折中值。太短看不到光伏晚高峰后的趋势,太长则预测末段误差重新变大。每隔 1 小时滚动一次比较常见,也有系统在光伏快速变化的午间改成 0.5 小时滚动一次。
3.4 实时层:用规则分配器兜住预测误差
实时层不重新求解 MILP,而是根据实际偏差做一个最简决策树:先保电解槽最小出力,再用电池吸收波动,最后才调整电解槽功率。
def dispatch_real_time(soc, h2_level, pv_actual, wind_actual, plan, P, dt=5/60): # plan 是日内层给出的最新功率指令 target_el = plan["P_el"][0] imbalance = (pv_actual + wind_actual - plan["P_buy"][0] - P["base_load"] - target_el) if abs(imbalance) < 0.05: return target_el, 0.0 # 电池先吸收偏差 if imbalance > 0 and soc < P["soc_max"]: charge = min(imbalance, P["batt_ch_max"], (P["soc_max"] - soc) * P["batt_cap"] / dt) return target_el, charge elif imbalance < 0: discharge = min(-imbalance, P["batt_dis_max"], (soc - P["soc_min"]) * P["batt_cap"] / dt) return target_el, -discharge # 电池饱和后,才按爬坡限幅调整电解槽 new_el = target_el + imbalance new_el = max(P["el_min"], min(P["el_max"], new_el)) return new_el, 0.0参数说明:dt 是实时调度周期。电池能吸收的偏差受两个条件限制,一个是瞬时功率上限,一个是剩余可充放能量,后者是实际项目中最容易漏掉的一个量。SOC 单位是无量纲,乘以电池容量后才是 MWh,这一步单位换算错了会让电池行为完全失真。
4. 让制氢负荷参与需求响应:约束怎么写、代码怎么改
4.1 需求响应在制氢系统里到底能调什么
风光氢储系统的需求响应,和普通楼宇空调削峰不太一样。制氢负荷属于可平移、可削减的工业负荷,但调整它会影响氢气产量和储氢罐状态,所以不能简单“让电解槽降 20% 功率”就完事。
可平移负荷的意思是:把某几个小时的制氢任务搬到另外几个小时完成。例如午间光伏大发时段多制氢,夜间电价低谷时段多制氢,早晚高峰时段少制氢。可削减负荷的意思是:响应电网削峰指令直接少产氢,如果当天产氢任务无法完成,就要从外部购氢或增加其他时段产氢量。
对调度模型而言,平移量必须满足总能量守恒,削减量要有补偿单价,并且二者都要限制在设备物理可调范围之内。这是需求响应建模里最容易出错的点。
4.2 把需求响应写成数学模型:平移量与削减量的区别
需求响应变量是连续变量,单位是 MW。平移量 DR_shift[t] 为正表示该时段多用电,为负表示少用电。为了平移不产生额外能量消耗,任意时段满足:
Σ(DR_shift[t] × Δt) = 0
削减量 DR_cut[t] 只能取非负值,并且不超过电网协议里给定的可响应容量。目标函数里要加上削减补偿收入项,这样优化器才有动力在某些时段主动少用电。
另外,不能在功率平衡里直接把 DR_shift 加到再生能源侧。正确做法是把 DR_shift 放到负荷侧,让模型自己去匹配新增的购电或新能源消纳需求。
4.3 在原有代码上接入需求响应模块的改动清单
在原模型基础上增加需求响应,需要改动三处:变量定义、功率平衡和目标函数。
# 在目标函数中新增收入项 prob -= pulp.lpSum(dr_price[t] * DR_cut[t] for t in range(T)) # 新增两组变量 DR_shift = [pulp.LpVariable(f"dr_shift_{t}", lowBound=-dr_cap, upBound=dr_cap) for t in range(T)] DR_cut = [pulp.LpVariable(f"dr_cut_{t}", lowBound=0, upBound=dr_cut_limit[t]) for t in range(T)] # 修改功率平衡方程,把平移量放到负荷侧 for t in range(T): prob += P_buy[t] + P_pvu[t] + P_wu[t] + P_dis[t] - P_ch[t] \ == P_el[t] + P["base_load"] + DR_shift[t] - DR_cut[t] # 平移量能量守恒 prob += pulp.lpSum(DR_shift[t] for t in range(T)) == 0逻辑说明:DR_shift 加了后,功率平衡如果不等,模型会自动调整购电或电池出力。DR_cut 如果增加,右侧负荷变小,相当于主动削了一部分用电需求。目标函数里减去补偿收入,优化器就会在高电价或电网发出激励信号时优先考虑削减用电。
参数说明:dr_cut_limit 需要按日前预测的制氢任务来动态计算。如果当天产氢目标已经很高、储氢罐接近满罐,削减空间就很大;如果储氢罐本来就不满,削减可能会导致末端氢量不足,模型会自己权衡,但前提是目标函数中必须给这种不足留一个惩罚项或约束条件。
5. 常见问题排查:五个“看起来优化了结果却没法用”的典型工况
5.1 滚动窗口SOC漂移:最后总能优化出个不存在的电量
现象:日内滚动跑了三轮之后,电池SOC逐渐顶到上界或者跌穿下界,仿真结果和实际电站数据对不上。
原因:滚动窗口只考虑未来 4~6 小时,目标函数不会为窗口之外的电量价值买单。如果没有给末端SOC加约束或惩罚项,优化器总把电池往“最方便”的方向用。前面代码里写了SOC[T] >= SOC[0] - 0.05,这是最常见的兜底做法。
解决:在日内滚动里显式读取上一轮窗口的SOC终值,作为下一轮初始SOC,同时给末端SOC加软约束或目标项。比如把(SOC[T] - SOC_target)^2改成线性惩罚penalty * |SOC[T] - SOC_target|,或者直接把末端SOC约束设成初始值附近 5% 的窗口。
5.2 电解槽频繁启停:经济性最优让设备受不了
现象:优化结果里电解槽每小时启停一次,买电价格低就开机,价格高就停机。目标函数看起来很漂亮,实际现场运维看一眼直接拒绝执行。
原因:模型只惩罚了功率费用,没有惩罚启停动作本身。尤其当氢价和电价联动时,二元变量自由度很大,优化器会利用启停来规避低效运行区间。
解决:给每次开机加固定费用,例如“启动成本 500 元/次”,在目标函数里写start_cost[t] >= 500 * (u_el[t] - u_el[t-1])。更稳妥的是加最小运行时间约束,比如开机后至少连续运行 4 小时。这两招都可以在 MILP 框架内实现,不需要改求解器。
5.3 需求响应补偿被优化器“白捡”:逻辑冲突
现象:把需求响应补偿加到目标函数后,优化结果每天都在削负荷,但产氢量也同时大降,制氢协议无法完成。
原因:DR_shift 平移约束写对了,但 DR_cut 削减量没有和产氢目标绑定。削减负荷直接降低电解槽用电,如果系统必须满足日初设定的总产氢量,模型应当被迫在其他时段补产,而不是直接砍产量。
解决:把总产氢量约束从“软目标”改成“硬约束”:Σ H2p[t] >= H2_demand_total。如果做不到,就引入购氢变量或削减量惩罚。这会让优化器在收益与协议之间自行取舍,而不是无脑响应电网。
5.4 数据拼接出现时间索引错乱:模型直接不可行
现象:跨日预测数据拼接之后,模型求解状态是 Infeasible,检查约束却发现单条约束都对。
原因:CSV 文件经过 resample 和 interp 后,时间索引有重复或缺失,功率平衡方程里的数组长度不一致,出现错位。经常是前一天最后 15 分钟一条记录和后一天第一条记录重叠。
解决:每次读取数据后先做df = df[~df.index.duplicated(keep="last")],再 resample 成整点,最后校验len(df) == 24或对应窗口长度。这个检查放到函数入口,比事后排错省一个下午。
5.5 数量级差过大的MILP黑匣子:求解器结果不稳定
现象:同样的数据,今天解出来的调度曲线和昨天不一样,看似是随机波动,换了台机器跑结果又变。
原因:目标函数里价格单位是元/MWh,氢量是 kg,功率是 MW,三者数量级相差三到四个量级。CBC 这类开源求解器在数值条件差时会随机卡在某些节点上,结果不稳定。
解决:所有变量统一归一化。功率用 pu 值或 MW、能源用 MWh、价格统一成元,控制最大数量级在 1~1000 之间。如果氢气每公斤价格是 28 元,那要把单位改成元/kg,功率改成 kW 或 MW,让各系数倍数尽量一致。这个步骤是调需求响应和滚动优化之前必须做的“地基工程”,千万别偷懒。
6. 验证调度方案到底该看哪些指标:从离线回测到在线闭环
调度方案写完之后,第一步不是接现场,而是做离线回测。拿一两个月的实际历史风光数据、电价和负荷数据,把日前、日内、实时三层模拟跑一遍,记录关键指标。
最值得对比的指标有四类:弃电率(弃风弃光量占可用新能源比例)、制氢量完成率(实际产量与目标产量比值)、购电成本节省比例(相对不做优化的固定运行策略)、电池SOC越界次数。计算这些指标时要注意,弃电率必须按“可用新能源总量”算,而不是按实际消纳量算,否则分母一缩水,弃电率看起来会很漂亮。
第二步是做对比测试。建议至少跑三个对照组:固定运行策略(电解槽恒功率)、单一日前调度(不滚动)、多时间尺度滚动调度。只有拿滚动调度和固定策略比,才能看出优化的价值;只有和单层日前比,才能看出多尺度到底多赚了多少钱。这个对比表放到项目汇报里非常有用。
第三步是半实物验证。很多项目用历史重放的方式,把过去某天的预测数据喂给调度系统,再把调度系统输出的指令发给仿真模型或就地控制器,观察设备响应是否越限。我自己的习惯是,拿到一套新数据先跑一遍确定性日前,再跑 10 天连续滚动回测,最后才接实时闭环。顺序颠倒十个项目有九个要翻车。这条血泪经验希望帮到你。
本文还有配套的精品资源,点击获取