做风光制氢合成氨系统优化,最难的不是把风电、光伏的数学模型搭出来,而是把论文里那些看起来很漂亮的公式,变成一个能跑、能调、能复现的Python程序。我自己在复现这类论文的时候踩过不少坑,这里把整个思路、建模过程和代码框架拆开讲一遍,照着做基本能跑通一个完整的容量配置优化模型。
这个项目的核心是:在给定风光资源时序数据的前提下,通过混合整数线性规划(MILP)求出风电、光伏、电解槽、储氢罐、合成氨装置的最优容量配置和运行策略,使得系统的年化总成本最低,同时满足年产氨量目标和各种运行约束。
1. 风光制氢合成氨优化到底在优化什么
1.1 系统的物理结构与能量流
先说清楚这个系统长什么样。风光制氢合成氨系统,本质上是一条从可再生能源到化工产品的能量转化链条,全流程包含四个主要环节:
风电场和光伏电站负责产出电能,这部分电有三个去处:一是直接供给电解槽制氢,二是供给合成氨装置和辅助设备,三是多余的电可以弃掉(或者论文里有的会考虑上网,但大部分离网系统不设上网选项)。
电解槽把水电解成氢气和氧气,氢气经过缓冲罐进入储氢罐,储氢罐起两个作用:平抑风光出力波动和合成氨装置连续运行之间的供需矛盾,以及为合成氨提供稳定的原料气。这也是整个系统里最重要的缓冲环节。
合成氨装置用氢气和氮气(空分装置提供)在高温高压下合成氨。这个环节的工艺特点决定了它不能频繁启停,所以论文里通常会加最小运行时间约束或者最少运行小时数约束。
能量流的关系可以简化为:风光发电量 = 电解槽耗电量 + 合成氨装置耗电量 + 辅助设备耗电量 + 弃电量。在优化模型里,每小时的电力平衡、氢气平衡和氨产量平衡是三条核心约束,缺一不可。
1.2 优化目标和决策变量
这个问题的优化目标很明确,就是年化总成本最小。年化总成本包含三块:
投资成本的年化值,就是把风电、光伏、电解槽、储氢、合成氨装置的总投资费用按寿命期和折现率换算成每年的成本。计算方法是:设备单价乘以配置容量,再乘以资本回收因子CRF,CRF = r(1+r)^n / ((1+r)^n - 1),其中r是折现率,n是设备寿命。
运行维护成本,一般按设备年运维费率乘以投资成本计算,或者按运行小时数和单位运维成本计算。
运行成本,包括购水成本、空分装置耗电成本、以及弃电惩罚(如果有的话)。水电解制氢的耗水量大约是每千克氢气消耗9-10升水,这个在后续成本核算中会用到。
决策变量分两类:一类是连续变量,包括每个小时的风电出力、光伏出力、电解槽输入功率、制氢量、储氢罐储氢量、合成氨装置运行功率和氨产量;另一类是整数变量,包括设备容量配置(离散化的机组台数、电解槽台数)和合成氨装置的启停状态。
这里面最需要注意的一点是:电解槽和合成氨装置的容量都是离散化处理的,比如电解槽单台额定功率1MW,500MW的配置就是500台,这个整型约束会显著增加模型求解难度,也是MILP比LP难解的根本原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 建模思路:从论文公式到Python代码
2.1 风电、光伏出力建模
风光出力建模是整个优化模型的数据基础。模型质量再高,输入的风光时序数据不准,结果也没有参考价值。
风电出力模型有两种做法。简化做法是:已知风电场额定功率P_wt_rated,通过风速-功率曲线计算出力,单台风机输出功率P_out = 0,v < v_cut_in或v > v_cut_out;P_out = P_rated * (v^3 - v_cut_in^3)/(v_rated^3 - v_cut_in^3),v_cut_in <= v < v_rated;P_out = P_rated,v_rated <= v <= v_cut_out。用Python实现时可以直接向量化,避免循环。
光伏出力模型需要用辐照度数据,P_pv = eta_pv * S * G,其中eta_pv是光伏板转换效率(通常取15%-20%),S是光伏板总面积,G是水平面总辐照度(kW/m²)。注意这里面积S是变量,由光伏容量配置决定,所以光伏出力和配置容量是线性关系。
论文复现时有个细节特别容易忽略:时空分辨率的一致性。风电和光伏数据的采样间隔是1小时,那模型的时间步长就必须是1小时,如果风光数据来自不同的数据源,还要检查数据的时间戳对齐问题。我在实际处理中遇到过风光数据时间错位8小时的情况,导致优化结果严重失真。
如果论文用典型日而不是全年8760小时的时序数据,需要做场景削减。目前主流的方法是K-means聚类,把365天(或12个月)的风光出力曲线聚类成若干个典型日,每个典型日附带权重系数,这样求解规模大幅下降。聚类的具体代码后面会展示。
2.2 电解槽与储氢建模
电解槽建模是风光制氢系统的核心环节。论文里常见的处理方式有两种:
简单版本是不考虑电解槽的启动特性和负荷调节范围,直接用能量转换效率来描述:E_H2(t) = P_EL(t) * eta_EL / LHV_H2,其中E_H2(t)是产氢速率(kg/h),P_EL(t)是电解槽输入电功率(MW),eta_EL是电解效率,LHV_H2是氢气低位热值(取33.33 kWh/kg,不同论文取值略有差异)。
复杂版本会考虑电解槽的最小运行负荷率。碱性电解槽的最小技术出力一般是额定功率的20%-40%,也就是说电解槽输出功率不能低于某个比例,否则电解槽自动停机。这个约束在数学上需要引入二进制变量y_EL(t):P_EL_min * y_EL(t) <= P_EL(t) <= P_EL_max * y_EL(t),y_EL(t)表示t时段电解槽是否开机。
储氢模型比较简单,就是一个带有充放氢效率的能量平衡方程:S_H2(t+1) = S_H2(t) + E_H2_in(t) * eta_st - E_H2_out(t),同时储氢量有上下限约束:S_min <= S_H2(t) <= S_max,S_max就是储氢容量决策变量。
这里有一个经验判断:论文如果强调"平抑波动"和"提高系统柔性",储氢模型一般会采用充放氢效率分开建模的方式;如果只是粗略估算,那就用常数效率。复现的时候要看清论文里的假设,不要上来就用最复杂的版本。
2.3 合成氨单元建模与约束线性化
合成氨单元的建模是整篇论文里最需要动脑子去线性化的部分。实际合成氨过程有复杂的化学反应动力学,但用于容量优化的模型一般采取黑箱建模,关键就两个变量:
合成氨产率R_NH3(t)(t/h)和氢耗量E_H2_am(t)之间满足物料平衡关系:E_H2_am(t) = R_NH3(t) * alpha,其中alpha是生产1吨氨所需的氢气量。理论上,氨合成的化学方程式是N2 + 3H2 -> 2NH3,根据摩尔质量计算,生产1吨氨需要约0.176吨(即176 kg)氢气。
合成氨装置运行时的耗电量,由合成压力、循环压缩机和辅助设备决定,论文里一般简化成与氨产量成正比:P_AM(t) = beta * R_NH3(t),beta是单位氨产量的综合电耗,通常取0.1-0.3 MWh/t。
最关键的约束是合成氨装置的连续运行约束:由于启停成本高、工艺要求苛刻,合成氨装置一旦开启,必须连续运行至少N小时才能停机。这个约束用线性不等式表达相对复杂,论文里有的用最小日运行时间,有的用最小连续运行时间。在Python里实现时,我一般添加如下约束(以最小连续运行时间为例):
y_AM(t) - y_AM(t-1) <= y_AM(tau),tau从t+1到t+N-1。这条约束的意思是:如果装置在第t小时由停机转为开机(y_AM(t)-y_AM(t-1)=1),那么接下来N-1个小时装置都必须保持开机状态。
另一个常被忽略的约束是合成氨装置的出力范围限制:R_NH3_min * y_AM(t) <= R_NH3(t) <= R_NH3_max * y_AM(t),即装置在不运行时产量必须为0,运行时产量必须在一定范围内波动。
3. Python代码框架与关键实现
3.1 代码整体结构
我复现这个系统的代码采用模块化分层设计。顶层是一个主脚本,负责读取数据、构建模型、求解和输出结果;下面分四个模块:数据管理模块、设备参数模块、约束建模模块和结果分析模块。
数据管理模块负责从Excel或CSV读取风光时序数据、聚类生成典型日、统一时间索引。设备参数模块定义设备的技术参数和经济参数,比如电解槽效率、设备单位投资成本、寿命、折现率等。约束建模模块基于Gurobi或COPT构建优化模型的全部变量和约束。结果分析模块把求解结果可视化(存成图表或直接在Jupyter中展示)。
如果用的是Gurobi,求解MILP的速度在同类求解器里确实很快,缺点是商业授权对一般个人用户不友好。如果只是复现论文做学术研究,可以申请学术授权。如果没有Gurobi授权,也可以考虑用COPT(运筹优化求解器,国内团队开发,社区版也开放)或者开源的CBC。但CBC的求解性能在大型MILP问题上和Gurobi的差距比较明显,如果模型规模大,优先选Gurobi或COPT。
3.2 场景生成:从8760小时到典型日
先说数据准备。如果你处理的地区没有实测风光数据,可以用NASA MERRA-2或者ERA5再分析数据,也可以直接用论文附录给出的数据。这里的关键是场景削减。用K-means对全年逐时风电出力系数曲线和光伏出力系数曲线进行聚类,聚成K个典型日场景,每个场景带有权重。
一个具体的例子,数据维度是8760小时(每天24小时,365天),聚类前的样本形状是365x24,聚类数为K=20,迭代次数不用太大,基本能收敛。
python复制import pandas as pd
import numpy as np
from sklearn.cluster import KMeans
# load_data: DataFrame with columns ['datetime', 'wind_pu', 'solar_pu']
# wind_pu 和 solar_pu 分别是风电场和光伏电站的归一化出力(实际出力/额定功率)
df = pd.read_csv('weather_data.csv', parse_dates=['datetime'])
# 整理成365行 x 24列的数据矩阵
wind_daily = df['wind_pu'].values.reshape(365, 24)
solar_daily = df['solar_pu'].values.reshape(365, 24)
# 拼接两类特征(风力+光伏),这样聚类出来的典型日同时考虑了风和光的形态
features = np.hstack([wind_daily, solar_daily]) # shape: (365, 48)
K = 20
kmeans = KMeans(n_clusters=K, random_state=42, n_init=10)
labels = kmeans.fit_predict(features)
# 每个典型日场景的权重 = 所属簇的样本数 / 总天数
weights = np.bincount(labels, minlength=K) / 365.0
# 典型日风电出力曲线(每个簇中心的前24列)
typical_wind = kmeans.cluster_centers_[:, :24]
# 典型日光伏出力曲线(每个簇中心的后24列)
typical_solar = kmeans.cluster_centers_[:, 24:]
np.savez('typical_scenarios.npz', wind=typical_wind, solar=typical_solar, weights=weights)
聚出来的典型日越多,精度越高,但求解规模也越大。论文复现阶段建议先从K=10或K=20开始,跑通后再增加K值看结果稳定性。
做K-means之前,需要注意对光伏夜间数据进行处理。光伏出力在夜间为0,聚类时会聚成很多形状相似的夜间段,没什么区分度,所以有的复现会把数据切割成"白天段+夜间段"分别聚类,再组合成完整典型日。
3.3 优化模型主代码与求解
模型代码是核心。我用Gurobi的Python接口写一个简化的可运行版本,展示主要约束如何落地。先定义基础数据和变量。
简化版模型假设是单场景确定性优化(即不考虑多场景随机规划,直接用典型日场景加权),这是大多数论文的基础版本。实际上论文会扩展成随机优化或两阶段鲁棒优化,但先把确定性版本跑通,再往上加复杂度会容易得多。
python复制import gurobipy as gp
from gurobipy import GRB
# 场景与时长
# T: 单个典型日的小时数 24
# S: 场景数 K
T = 24
S = 20
# 典型日数据加载(上面np.savez的结果)
data = np.load('typical_scenarios.npz')
wind_scene = data['wind'] # shape (S, T) 风电归一化出力
solar_scene = data['solar'] # shape (S, T) 光伏归一化出力
weights = data['weights'] # shape (S,)
# 设备参数示例
eta_EL = 0.70 # 电解槽效率
LHV_H2 = 33.33 # 氢气低位热值 kWh/kg
alpha_am = 176.0 # 每吨氨需要氢气量 kg/t
beta_am = 0.15 # 每吨氨电耗 MWh/t
P_EL_min_pu = 0.3 # 电解槽最小出力系数
P_AM_min_pu = 0.6 # 合成氨最小负荷系数
# 年化成本所需参数
crf_wt = 0.063 # 风电资本回收因子(基于寿命25年,折现率5%)
crf_pv = 0.073 # 光伏资本回收因子(寿命20年)
crf_el = 0.15 # 电解槽资本回收因子(寿命10年)
crf_st = 0.11 # 储氢(寿命15年)
crf_am = 0.11 # 合成氨装置(寿命15年)
cost_wt = 7200.0 # 元/kW
cost_pv = 4500.0 # 元/kW
cost_el = 3000.0 # 元/kW
cost_st = 12000.0 # 元/kg(储氢容量按kg氢气算)
cost_am = 3500000.0 # 元/(t/d) 即每天产一吨氨的装置投资
hours_per_year = 8760.0
m = gp.Model('wind_solar_h2_nh3')
# ---------- 容量变量(连续变量,省去离散整数变量以简化)----------
Cap_wt = m.addVar(lb=0, ub=3000, name='Cap_wt') # MW
Cap_pv = m.addVar(lb=0, ub=2000, name='Cap_pv') # MW
Cap_el = m.addVar(lb=0, ub=1000, name='Cap_el') # MW(电解槽总功率)
Cap_st = m.addVar(lb=0, ub=50000, name='Cap_st') # kg(储氢容量)
Cap_am = m.addVar(lb=0, ub=500, name='Cap_am') # t/d(合成氨日产量基准)
# ---------- 运行变量(三个维度:场景、时段)----------
p_wt = m.addVars(S, T, name='p_wt') # 风电出力 MW
p_pv = m.addVars(S, T, name='p_pv') # 光伏出力 MW
p_el = m.addVars(S, T, name='p_el') # 电解槽功率 MW
h2_prod = m.addVars(S, T, name='h2_prod') # 产氢量 kg/h
h_tank = m.addVars(S, T, name='h_tank') # 储氢量 kg
h_am = m.addVars(S, T, name='h_am') # 合成氨用氢 kg/h
p_am = m.addVars(S, T, name='p_am') # 合成氨耗电 MW
r_am = m.addVars(S, T, name='r_am') # 合成氨产量 t/h
y_el = m.addVars(S, T, vtype=GRB.BINARY, name='y_el') # 电解槽开停
y_am = m.addVars(S, T, vtype=GRB.BINARY, name='y_am') # 合成氨开停
# ---------- 目标函数:年化总成本最小 ----------
inv_cost = (cost_wt * Cap_wt * 1000 * crf_wt
+ cost_pv * Cap_pv * 1000 * crf_pv
+ cost_el * Cap_el * 1000 * crf_el
+ cost_st * Cap_st * crf_st
+ cost_am * Cap_am * 365 * crf_am)
# 运维成本简化为投资成本的比例系数
om_cost = 0.03 * (cost_wt * Cap_wt * 1000 + cost_pv * Cap_pv * 1000
+ cost_el * Cap_el * 1000 + cost_st * Cap_st
+ cost_am * Cap_am * 365)
m.setObjective(inv_cost + om_cost, GRB.MINIMIZE)
上面的代码先出了变量和目标函数框架,接下来加约束。设备变量单位需要特别注意:风电、光伏投资成本单位是元/kW,但又用Cap_wt(MW)来做变量,所以乘以1000换算成kW。合成氨的投资成本单位是元/(t/d),容量变量Cap_am是t/d,目标函数中直接乘以365变成年产量基准,资本回收因子对应的是装置寿命。
加约束部分,电力平衡约束、氢气平衡约束、储氢的动态方程、设备容量上限约束等都要逐条写。
python复制# ---------- 电力平衡约束:风电+光伏 = 电解槽耗电+合成氨耗电+弃电 ----------
curtail = m.addVars(S, T, name='curtail') # 弃电量
for s in range(S):
for t in range(T):
m.addConstr(p_wt[s,t] + p_pv[s,t] == p_el[s,t] + p_am[s,t] + curtail[s,t])
# 风光出力上限:滚动接入的光伏、风电出力不能超过风光资源乘以装机容量
for s in range(S):
for t in range(T):
m.addConstr(p_wt[s,t] <= wind_scene[s,t] * Cap_wt)
m.addConstr(p_pv[s,t] <= solar_scene[s,t] * Cap_pv)
# 电解槽:功率与产氢量、启停约束
for s in range(S):
for t in range(T):
m.addConstr(h2_prod[s,t] == p_el[s,t] * eta_EL / LHV_H2) # kg/h
m.addConstr(p_el[s,t] <= Cap_el * y_el[s,t])
m.addConstr(p_el[s,t] >= P_EL_min_pu * Cap_el * y_el[s,t])
m.addConstr(p_el[s,t] >= 0)
# 储氢罐动态方程(场景内循环)
for s in range(S):
m.addConstr(h_tank[s,0] == 0.5 * Cap_st) # 初始储氢为半罐
for t in range(T):
if t == 0:
m.addConstr(h_tank[s,t] <= Cap_st)
else:
m.addConstr(h_tank[s,t] <= Cap_st)
m.addConstr(h_tank[s,t] == h_tank[s,t-1] + h2_prod[s,t] - h_am[s,t])
# 合成氨装置:氨产量约束、用氢约束、电耗约束、功率范围约束
for s in range(S):
for t in range(T):
m.addConstr(h_am[s,t] == alpha_am * r_am[s,t]) # 氢耗 = 氨产量 * 单耗
m.addConstr(p_am[s,t] == beta_am * r_am[s,t]) # 电耗 = 氨产量 * 单位电耗
m.addConstr(r_am[s,t] <= Cap_am / 24.0 * y_am[s,t]) # 按日产量折算小时上限
m.addConstr(r_am[s,t] >= P_AM_min_pu * Cap_am / 24.0 * y_am[s,t])
# 年产氨目标约束:所有场景加权折算回整年
annual_nh3_target = 50000.0 # 吨/年
weighted_sum = gp.quicksum(weights[s] * gp.quicksum(r_am[s,t] for t in range(T)) * 365.0 for s in range(S))
m.addConstr(weighted_sum >= annual_nh3_target)
注意这里有两个容易踩坑的点:
一是场景权重折算。每个典型日聚类的权重weights[s]表示该典型日在全年365天中出现的比例,计算目标产量时,要把"典型日平均日产量"乘以365天再乘权重加权求和。上面代码里r_am[s,t]是场景s第t小时的氨产量(单位吨/小时),sum(t)得到该场景单日氨产量,乘weights[s]加权求期望日产量,再乘365得到全年期望产量。如果权重归一化正确(总和为1),这个写法的数量级是对的。
二是合成氨小时产量上限的换算。Cap_am的单位是t/d(每天多少吨),除以24得到每小时产量基准,再用y_am限制是否运行。这里容易被"日产量"和"小时产量"搞混,写代码时最好醒目标注一下。
连续运行约束在求解器里面一定要写。上面的代码简化掉了,实际跑的时候如果合成氨装置启动没有任何惩罚条件,求解器可能让它在只在高风光时段启停、低风光时段停机,这样虽然成本最低,但不符合物理实际。加连续运行约束的方法是:
python复制# 最小连续运行时间:假设 N_am_min = 8 小时
N_am_min = 8
for s in range(S):
for t in range(1, T):
# y_am[s,t] - y_am[s,t-1] <= y_am[s,tau] for tau in t+1..t+N_am_min-1
for tau in range(t+1, min(t+N_am_min, T)):
m.addConstr(y_am[s,t] - y_am[s,t-1] <= y_am[s,tau])
这个约束实现的是"如果从停机到开机,则未来N小时内不能停机"。边界处(例如t接近T时)只约束到T结尾即可,但是这样会导致典型日末尾的启停行为不稳定,因为一个典型日结束后下一个典型日不关联。这个边界问题,论文复现中通常通过将典型日场景循环连接或者增加跨场景约束来处理,但如果初始版本跑通就行,可以先忽略。
3.4 求解与结果输出
求解调优方面,MILP模型直接跑可能因为整数变量太多而很慢。先尝试用连续松弛(把y_el和y_am改成0到1的连续变量)跑一遍,确认模型没有不可行的问题,再改回整数变量求解。这样排查bug效率最高。
python复制m.optimize()
if m.status == GRB.OPTIMAL:
print(f'总年化成本: {m.ObjVal:.2f} 元')
print(f'最优风电容量: {Cap_wt.X:.2f} MW')
print(f'最优光伏容量: {Cap_pv.X:.2f} MW')
print(f'最优电解槽容量: {Cap_el.X:.2f} MW')
print(f'最优储氢容量: {Cap_st.X:.2f} kg')
print(f'最优合成氨容量: {Cap_am.X:.2f} t/d')
else:
m.computeIIS()
m.write('infeasible.ilp')
模型不可行的时候,一定要用computeIIS找冲突约束,不然就是你猜我猜,效率太低。我遇到过实际模型不可行的大多数原因是储氢罐初值设置不合理,或者合成的年产量目标和风光资源根本匹配不上。这时检查IIS能快速定位。
4. 参数设计与结果解读
4.1 案例参数设置
参照论文常见的算例,我设置了一个完整的案例,方便读者对照复现:
| 参数 | 数值 | 说明 |
|---|---|---|
| 风电场候选最大容量 | 3000 MW | 上限约束 |
| 光伏候选最大容量 | 2000 MW | 上限约束 |
| 电解槽效率 | 70% | 碱性电解槽 |
| 氢气低位热值 | 33.33 kWh/kg | 计算产氢量用 |
| 风电场投资单价 | 7200 元/kW | 含安装 |
| 光伏投资单价 | 4500 元/kW | 含安装 |
| 电解槽投资单价 | 3000 元/kW | 含配套 |
| 储氢投资单价 | 12000 元/kg | 按储氢容量 |
| 合成氨投资单价 | 350万元/(t/d) | 按日产量 |
| 折现率 | 5% | 年化计算 |
| 风电寿命 | 25年 | |
| 光伏寿命 | 20年 | |
| 电解槽寿命 | 10年 | |
| 合成氨装置寿命 | 15年 | |
| 储氢装置寿命 | 15年 | |
| 年产氨目标 | 50000吨/年 | 案例默认 |
| 场景数 | 20 | K-means聚类 |
这组参数来自几篇代表性论文和一个实际项目的经验值,不同论文差别很大。最关键的是电解槽投资成本,这几年从5000元/kW降到2000-3000元/kW都有可能,如果你复现的论文发表时间较早,参数要按论文里的来,否则结果无法对齐。
4.2 结果分析与敏感性测试
按上面的参数配置求解后,一个典型结果可能是这样的:
风电装机约900-1200MW,光伏装机约300-500MW,电解槽装机约400-600MW,储氢容量约15000-30000 kg,合成氨装置容量约140-180 t/d。年化总成本在2.5亿到4亿元人民币之间,其中风电投资占大头,电解槽投资其次。
但每个地区资源条件差异很大,风资源好的地区装机会偏向风电,光伏资源好的地区偏向光伏,这种差异是正常现象。如果你的结果偏差过大,首先检查风光时序数据的年均等效利用小时数:风电在2200-3000小时、光伏在1300-1800小时是合理的,如果风电利用小时低于1000,那结果必然往光伏倾斜。
敏感性测试也是论文复现的标准动作。一般会测以下参数的敏感性:
- 电解槽投资成本下降20%后,电解槽容量会怎么变
- 氢价或氨价变化对系统经济性的影响
- 风光资源年波动对结果的影响(用小波动区间生成多组数据测试)
- 年产氨目标提高10%时系统容量的扩展边际
这里有个有趣的现象:风光资源较差的地区,如果氨产量目标很高,优化结果会出现"大规模弃电+大规模储能"并存的情况。原因是为了满足氨产量的刚性需求,在风光不佳的时段必须储存足够多的氢,而储存的氢来自风光好时段的多余电力,但为了获得那部分电力需要把风电、光伏容量建得特别大,于是很多时段发的电用不完只能弃掉。这个"弃电换储氢"的权衡是系统优化的核心张力,也是论文里最值得分析的内容。
跑敏感性测试时,我不建议把每个参数都重跑一遍全模型。一个高效的技巧是:先只改变目标函数或约束右侧的某个参数,让Gurobi的温暖启动(warm start)从上一个解开始搜索,通常几秒钟就能收敛到一个接近最优的解,对比最优解的差距可以用MIP gap判断。
5. 常见问题与排查技巧实录
5.1 求解器相关:模型求解慢怎么办
风光制氢合成氨的MILP模型,如果加了时序8760小时加全年连续运行约束,变量数很容易超过百万,直接求解非常慢。常见的解决方案有两种:
方案一:用典型日替代全年时序。这是论文最常用的做法,把8760小时聚类成20-50个典型日。即便这样,20个场景乘以24小时,再乘以启停变量,也有近千个二进制变量,性能好的求解器一般能在几秒到几分钟内解决。
方案二:分解算法。如果非要用全年8760小时模型,可以用Benders分解把主问题(容量配置)和子问题(月度或季度运行模拟)分开求解。主问题只做容量决策,把容量变量固定后,子问题可以按天或按周解耦并行求解。这个方案写起来复杂,但论文复现做到这一步,基本就是高水平复现了。
模型求解慢的时候,最实用的技巧是放宽MIP gap。Gurobi里设m.setParam('MIPGap', 0.02),表示允许2%的最优性差距。对工程优化来说,2%的误差完全可接受,求解时间可能从几十分钟降到几分钟。
5.2 建模踩坑:热门的几个问题,这里有详细分析
问题1:模型跑出来的结果一堆0容量,全部设备不建设
这是最典型的问题。原因是目标函数只包含成本,而约束里如果只有"年氨产量>=目标",没有强制系统必须运行,求解器发现不建任何设备、不产任何氨,目标成本为零,最优化,但违反年产氨目标约束。所以这种情况大概率是年产量目标约束没写对,常见原因包括场景权重没乘进去、单位换算错误(把t/d和t/h弄混)、或者目标约束被注释掉了。
问题2:储能一直充满,为什么不放电
储氢罐的初始值设为半罐,如果全年都充满,说明电解槽产氢永远过剩,说明电解槽容量相对于合成氨需求过大,或者合成氨装置不连续运行导致耗氢不足。可以从降低电解槽容量上限,或增大合成氨日产量目标入手排查。
问题3:代码报错提示变量没定义,但明明定义了
注意Gurobi的addVars返回的是一个tupledict,用p_wt[s,t]访问没问题,但如果S和T的顺序搞反了(比如循环里写成了p_wt[t,s]),会出现KeyError。排查这种问题时,打印一下p_wt.keys()大概看一下索引结构,比盯着代码看半天效率高。
问题4:不同场景之间的储氢罐不连续
典型日模型默认每个场景都是独立的,场景A结束时的储氢量和场景B开始时的储氢量没有联系。这种"场景割裂"会影响结果精度,因为真实系统的储氢是一个跨天连续的过程。解决办法是加跨场景约束,比如让所有场景的初始储氢一致、最终储氢一致,或者直接采用全年8760小时时序模型。不过复现时这属于可选项,很多论文本身也做了这个简化。
问题5:结果对初始储氢量太敏感,稍微改一点结论就变
初始储氢假设在论文里属于"不影响主结论但影响数值"的敏感性参数。不同论文采用的初始储氢不同:有的取半罐,有的取1/3罐,有的取最低储氢量。如果复现的时候发现结果对初始储氢特别敏感,可以考虑把初始和终态储氢设为同一个变量(循环约束),这样模型会自行决定最优的初始储氢水平,不用外部指定,结果更稳定。
5.3 高效复现论文的方法论
最后说点方法论层面的东西。论文复现最大的坑是"对着公式抄代码",很多论文的公式符号混乱、单位不清,硬抄只会越抄越乱。我自己的经验是先写一个最小的极简模型,把公式完全剥离,只保留物理含义,然后用"能力验证"的方式逐步添加约束。
具体操作是:
先用一个极简模型,只含风电、光伏、电解槽,不包含储氢和合成氨,验证电力平衡和制氢逻辑对不对。在这个极简模型里,制氢量应该等于风电光伏供电总量乘以效率再除以热值。
跑通后加上储氢罐,验证储氢动态方程和容量约束。
再加上合成氨装置,验证启停约束和连续运行约束。
最后加上年产量目标和目标函数的经济性参数,得到完整模型。
每一步都对比手工计算或常识判断的结果,模型跑出的数据才可能是可靠的。
做完完整模型后,还要做鲁棒性检验:把典型日数量从10增加到50,如果最优容量结果变化幅度小于5%,说明聚类数足够,结果稳定可信;如果变化很大,说明场景削减不足以反映风光资源的变化特性,需要增加场景数。
我最初做这个项目时也走过弯路,一开始就照着论文完整公式写代码,结果模型不可行,查了三天,最后发现是"年产量目标的单位换算错了,把日产量当成小时产量乘了24倍"。所以强烈建议分步骤搭建模型,每加一个模块就验证一次,调试成本会大幅降低。
