ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

水风光互补发电系统容量配置与调度策略联合优化实战

水风光互补发电系统容量配置与调度策略联合优化实战

简介:这份资源面向电力系统规划、设计与运行工程师及可再生能源优化调度方向的研究人员,围绕“3060”双碳目标下如何提升可再生能源消纳能力与系统稳定性展开。内容涵盖水、风、光三个子系统的Simulink建模与功率互补验证,基于功率供应缺失率、失负荷概率、弃电率、互补性指标与单位运行成本五个指标的容量配置优化,以及考虑机组振动区约束的多目标调度模型,并采用NSGA-II与粒子群算法求解,附有可运行代码及逐段解释。资源包为1个PDF文件,约816KB,集中呈现数学模型、算法实现与工程案例验证,便于读者对照复现建模流程、理解约束处理思路并迁移到自身课题。已有54人学习,适合希望掌握互补特性分析、容量配置与调度求解方法的中高级读者参考。

1. 水风光互补发电系统:为什么容量配置和调度策略必须一起做

水风光互补发电系统,本质是把水电的快速调节能力、风电和光伏的出力特性捏在一起,让总出力曲线尽量平稳、尽量可预测。但很多团队在落地时踩了同一个坑:先把容量配好,再单独做调度优化。结果就是调度阶段发现容量方案根本跑不通,或者容量阶段假设的出力曲线和实际调度策略完全对不上。多目标优化在这里不是锦上添花,而是刚需——你要同时盯住可再生能源消纳能力、系统稳定性、投资成本三个方向,任何一个方向单独拉满,另外两个都会翻车。这套方案适合做微网、区域综合能源、流域梯级电站群规划的工程师,也适合正在写相关课题、需要一套可复现代码框架的研究生。接下来我会按“模型怎么建 → 代码怎么跑 → 参数怎么调 → 坑在哪”的顺序,把容量配置与调度策略的联合优化讲透。

2. 多目标优化模型怎么建:从目标函数到约束条件

2.1 三个目标函数的数学表达与取舍逻辑

容量配置和调度策略联合优化的第一步,是把“提高消纳能力”和“提高系统稳定性”翻译成可计算的数学形式。常见做法是设三个目标:第一,可再生能源消纳率最大化,等价于弃风弃光率最小化;第二,系统出力波动最小化,通常用相邻时段净负荷的方差或最大爬坡率来衡量;第三,全生命周期综合成本最小化,包括初始投资、运维成本和购电成本。

这三个目标天然冲突。消纳率拉高意味着要多配储能或水电调节容量,成本就上去了;波动率压下去意味着水电要频繁启停或深度调节,设备损耗和运行成本又上去了。所以不要试图找一个“全能解”,而是求 Pareto 前沿,让决策者在前沿面上选一个折中点。

我一般会把目标函数写成归一化后的加权形式,方便后续用多目标算法求解:

import numpy as np def objectives(x, data): """ x: 决策变量向量,包含风电装机、光伏装机、水电扩容、储能容量 data: 包含风光出力曲线、负荷曲线、电价、成本系数的字典 返回: [弃风弃光率, 出力波动率, 综合成本] """ wind_cap, pv_cap, hydro_cap, storage_cap = x # 风光实际出力 = 装机 × 归一化出力曲线 wind_output = wind_cap * data['wind_profile'] pv_output = pv_cap * data['pv_profile'] renewable_output = wind_output + pv_output # 水电和储能参与调节后的净出力 hydro_output = np.clip(renewable_output - data['load'], 0, hydro_cap) storage_output = np.clip(renewable_output - data['load'] - hydro_output, -storage_cap, storage_cap) net_output = renewable_output + hydro_output + storage_output # 目标1: 弃风弃光率 curtailment = np.maximum(renewable_output - data['load'] - hydro_cap - storage_cap, 0) curtail_rate = np.sum(curtailment) / np.sum(renewable_output) # 目标2: 出力波动率(相邻时段净出力变化的标准差) fluctuation = np.std(np.diff(net_output)) # 目标3: 综合成本(万元) cost = (wind_cap * data['cost_wind'] + pv_cap * data['cost_pv'] + hydro_cap * data['cost_hydro'] + storage_cap * data['cost_storage'] + np.sum(np.abs(storage_output)) * data['cost_storage_op']) return [curtail_rate, fluctuation, cost]

这段代码的关键在于:水电和储能的出力是“被动”的,先由风光出力和负荷的差值决定,再受自身容量约束。参数说明:wind_profile和pv_profile是归一化到 [0,1] 的 8760 小时出力曲线;cost_wind等单位是万元/MW;storage_cap同时代表功率和能量容量,实际工程中要拆成两个变量,这里为了演示做了简化。

2.2 约束条件:那些容易漏掉的物理限制

约束条件写不全,优化结果就是数学游戏。必须包含的约束有:功率平衡约束、水电出力上下限约束、水电爬坡率约束、储能 SOC 约束、储能充放电功率约束、风光装机上限约束。其中水电爬坡率约束最容易被忽略,但它直接决定了系统稳定性目标能不能达成。

def constraints(x, data): wind_cap, pv_cap, hydro_cap, storage_cap = x penalty = 0 # 水电爬坡率约束:相邻时段出力变化不超过额定容量的 20% hydro_output = np.clip(wind_cap * data['wind_profile'] + pv_cap * data['pv_profile'] - data['load'], 0, hydro_cap) ramp = np.abs(np.diff(hydro_output)) penalty += np.sum(np.maximum(ramp - 0.2 * hydro_cap, 0)) * 1e6 # 储能 SOC 约束:简化处理,要求日终 SOC 回到初始值 storage_output = np.clip(wind_cap * data['wind_profile'] + pv_cap * data['pv_profile'] - data['load'] - hydro_output, -storage_cap, storage_cap) soc = np.cumsum(storage_output) / storage_cap penalty += np.sum(np.maximum(np.abs(soc) - 1.0, 0)) * 1e6 # 装机上限约束 penalty += np.sum(np.maximum(x - data['upper_bound'], 0)) * 1e6 penalty += np.sum(np.maximum(data['lower_bound'] - x, 0)) * 1e6 return penalty

这里用罚函数法把约束转成目标的一部分,适合配合启发式算法使用。0.2 * hydro_cap是爬坡率上限,实际工程中水电机组爬坡率通常在额定容量的 20% 到 50% 之间,具体看机组类型。1e6是罚函数系数,太小约束不起作用,太大导致搜索空间畸形,我一般取目标函数量级的 100 到 1000 倍。

2.3 容量配置与调度策略的耦合方式

耦合的核心在于:容量变量决定调度可行域,调度结果反过来评价容量优劣。常见做法是双层优化——外层用多目标算法搜索容量方案,内层用线性规划或规则调度求该容量下的最优调度。但双层优化计算量大,实际项目中我更常用“容量-调度联合编码”的方式:把容量变量和典型日的调度变量放在同一个决策向量里,用多目标算法一次性求解。

这种做法的好处是避免双层嵌套带来的计算爆炸,代价是决策变量维度高,需要配合降维策略。我一般会把调度变量按典型日压缩,比如选 4 个季节典型日,每个典型日 24 小时,调度变量就是 4×24=96 维,加上 4 个容量变量,总共 100 维。对于 NSGA-II 这类算法,100 维是可以接受的。

3. 用 Python 跑通多目标优化:NSGA-II 的完整实现

3.1 算法选型:为什么选 NSGA-II 而不是加权求和

加权求和把多目标压成单目标,一次运行只能得到一个解,要得到 Pareto 前沿得跑很多次,而且权重怎么设全凭经验。NSGA-II 一次运行就能得到一组分布均匀的 Pareto 解,决策者可以根据实际偏好选点。对于水风光互补这种三个目标互相拉扯的问题,Pareto 前沿的形状本身就是重要信息——它能告诉你消纳率每提高 1% 需要付出多少成本代价。

from pymoo.algorithms.moo.nsga2 import NSGA2 from pymoo.core.problem import Problem from pymoo.optimize import minimize import numpy as np class HydroWindPVProblem(Problem): def __init__(self, data): super().__init__(n_var=4, n_obj=3, n_constr=0, xl=np.array([100, 100, 50, 0]), xu=np.array([2000, 2000, 1000, 500])) self.data = data def _evaluate(self, X, out, *args, **kwargs): F = [] for x in X: obj = objectives(x, self.data) penalty = constraints(x, self.data) F.append([obj[0] + penalty, obj[1] + penalty, obj[2] + penalty]) out["F"] = np.array(F) algorithm = NSGA2(pop_size=100) problem = HydroWindPVProblem(data) res = minimize(problem, algorithm, ('n_gen', 200), seed=42, verbose=True)

n_var=4对应四个容量变量,xl和xu是搜索下界和上界,单位是 MW。pop_size=100是种群规模,n_gen=200是迭代代数。对于 4 维变量,100×200 的计算量在普通笔记本上大约跑 10 到 20 分钟。如果维度更高,需要增大种群规模或改用 NSGA-III。

3.2 典型日数据构造与归一化处理

风光出力曲线不能直接用原始数据,必须做归一化。我一般用历史一年的 8760 小时数据,按季节聚合成 4 个典型日,每个典型日 24 小时。归一化方法是除以装机容量,得到 [0,1] 区间的出力系数。

def build_typical_days(wind_raw, pv_raw, load_raw): """ wind_raw, pv_raw: 8760 小时原始出力数据(MW) load_raw: 8760 小时负荷数据(MW) 返回: 4 个典型日的风光出力系数和负荷 """ seasons = {'spring': [2,3,4], 'summer': [5,6,7], 'autumn': [8,9,10], 'winter': [11,12,1]} typical = {} for name, months in seasons.items(): mask = np.isin(pd.DatetimeIndex(data.index).month, months) wind_season = wind_raw[mask].reshape(-1, 24) pv_season = pv_raw[mask].reshape(-1, 24) load_season = load_raw[mask].reshape(-1, 24) # 取平均日曲线作为典型日 typical[name] = { 'wind_profile': wind_season.mean(axis=0) / wind_raw.max(), 'pv_profile': pv_season.mean(axis=0) / pv_raw.max(), 'load': load_season.mean(axis=0) } return typical

注意wind_raw.max()是全年最大出力,不是装机容量。如果原始数据已经除以装机容量,这里就不用再除。典型日构造的合理性直接影响优化结果的可信度,我一般会检查典型日的日发电量和全年平均日发电量是否接近,偏差超过 10% 就重新选典型日。

3.3 Pareto 前沿可视化与折中解选取

跑完 NSGA-II 后,res.F就是 Pareto 前沿上的目标值矩阵。可视化用三维散点图,三个轴分别是弃风弃光率、波动率和成本。

import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') ax.scatter(res.F[:,0], res.F[:,1], res.F[:,2], c='steelblue', s=20) ax.set_xlabel('Curtailment Rate') ax.set_ylabel('Fluctuation') ax.set_zlabel('Cost (10k CNY)') plt.show() # 折中解选取:归一化后取到理想点距离最小的解 F_norm = (res.F - res.F.min(axis=0)) / (res.F.max(axis=0) - res.F.min(axis=0)) ideal = np.zeros(3) dist = np.linalg.norm(F_norm - ideal, axis=1) best_idx = np.argmin(dist) best_x = res.X[best_idx] print(f"折中解容量配置: 风电 {best_x[0]:.1f} MW, 光伏 {best_x[1]:.1f} MW, " f"水电 {best_x[2]:.1f} MW, 储能 {best_x[3]:.1f} MW")

折中解选取没有绝对标准。如果项目对消纳率有硬性考核,就在满足消纳率阈值的解里选成本最低的;如果对稳定性要求高,就优先压波动率。我一般会同时给出三到五个候选解,让业主方根据实际预算和考核指标拍板。

4. 调度策略落地:从日前计划到实时修正

4.1 日前调度:用规则调度快速生成可行解

容量方案确定后,调度策略要回答“每个时段水电和储能各出多少力”。日前调度的时间尺度是 24 小时,分辨率 15 分钟或 1 小时。我常用规则调度打底:优先消纳风光,超出负荷的部分给储能充电,储能满了再给水电抽水或弃电;风光不足时,先放储能,再让水电补足。

def day_ahead_dispatch(wind_cap, pv_cap, hydro_cap, storage_cap, wind_profile, pv_profile, load): """ 规则调度:返回每个时段的水电出力、储能出力和弃电量 """ T = len(load) hydro = np.zeros(T) storage = np.zeros(T) curtail = np.zeros(T) soc = 0.5 # 初始 SOC 设为 50% for t in range(T): renewable = wind_cap * wind_profile[t] + pv_cap * pv_profile[t] net = renewable - load[t] if net >= 0: # 风光有余,先给储能充电 charge = min(net, storage_cap * 0.5, (1.0 - soc) * storage_cap) storage[t] = -charge soc += charge / storage_cap net -= charge # 再让水电少发或抽水 if net > 0: hydro[t] = max(0, hydro_cap - net) curtail[t] = max(0, net - hydro_cap) else: # 风光不足,先放储能 discharge = min(-net, storage_cap * 0.5, soc * storage_cap) storage[t] = discharge soc -= discharge / storage_cap net += discharge # 再让水电补足 hydro[t] = min(hydro_cap, -net) return hydro, storage, curtail

storage_cap * 0.5是储能最大充放电功率,假设为容量的一半,实际工程中要按 C-rate 换算。soc初始值设为 0.5 是为了避免第一天就出现 SOC 越限。规则调度的优点是快、可解释,缺点是经济性不是最优,适合作为实时修正的基准。

4.2 实时修正:滚动优化与反馈校正

日前计划基于预测数据,实际运行中预测误差不可避免。实时修正用滚动优化:每 15 分钟用最新预测数据重新求解未来 4 小时的调度计划,只执行第一个时段的结果。这样既能跟踪实际偏差,又不会频繁大幅调整。

def rolling_dispatch(actual_wind, actual_pv, actual_load, forecast_wind, forecast_pv, forecast_load, hydro_cap, storage_cap, horizon=4): """ 滚动优化:horizon 为预测时段数,每步只执行第一个结果 """ T = len(actual_load) hydro_actual = np.zeros(T) storage_actual = np.zeros(T) soc = 0.5 for t in range(T): # 用最新预测数据求解未来 horizon 个时段的调度 h, s, c = day_ahead_dispatch( wind_cap, pv_cap, hydro_cap, storage_cap, forecast_wind[t:t+horizon], forecast_pv[t:t+horizon], forecast_load[t:t+horizon] ) # 只执行第一个时段 hydro_actual[t] = h[0] storage_actual[t] = s[0] soc += s[0] / storage_cap soc = np.clip(soc, 0, 1) return hydro_actual, storage_actual

horizon=4对应 1 小时预测窗口(15 分钟分辨率)。预测窗口太短,优化效果不明显;太长,预测误差又大。我一般取 4 到 8 个时段。SOC 的反馈校正是关键:每次执行后更新 SOC,如果 SOC 偏离计划值超过 10%,就在下一步优化中调整充放电策略。

4.3 调度策略的评价指标

评价调度策略不能只看经济性,还要看对系统稳定性的贡献。我常用的指标有:弃风弃光率、净负荷波动率、水电爬坡次数、储能循环次数、SOC 越限次数。其中水电爬坡次数直接对应设备损耗,储能循环次数对应电池寿命。

指标日前规则调度滚动优化调度改善幅度
弃风弃光率8.2%5.7%-2.5%
净负荷波动率12.4 MW9.8 MW-21%
水电爬坡次数18 次/日12 次/日-33%
储能循环次数1.8 次/日1.4 次/日-22%

这张表是典型结果,具体数值取决于风光出力和负荷曲线。滚动优化在波动率和水电爬坡次数上的改善最明显,因为实时修正能提前感知预测偏差并平滑调整。

5. 避坑与排查:容量配置和调度联合优化的 5 个血泪教训

5.1 典型日选得太少,Pareto 前沿全是假解

现象:优化跑出来的 Pareto 前沿很漂亮,但把解代回全年 8760 小时仿真,弃风弃光率比优化结果高出一倍。原因:典型日只选了 4 个,每个季节一个,但季节内风光出力的波动被平均掉了,优化算法钻了空子。解决:典型日至少选 8 个,每个季节两个,或者用聚类方法从全年数据中自动选典型日。我一般用 K-means 对 8760 小时的风光联合出力做聚类,聚成 8 到 12 类,每类取中心作为典型日。

5.2 罚函数系数设错,约束形同虚设

现象:优化结果里水电爬坡率明显超过 20% 上限,但罚函数值很小,算法根本没当回事。原因:罚函数系数1e6相对于目标函数量级太小。成本目标可能在 1e5 量级,罚函数只有 1e6,算法宁愿违反约束也不愿增加成本。解决:先跑一次无约束优化,看目标函数的量级,罚函数系数取目标函数最大值的 100 到 1000 倍。或者改用可行性规则,在 NSGA-II 中设置n_constr参数,让算法原生处理约束。

5.3 储能 SOC 日终不归零,多日仿真直接崩

现象:单日调度没问题,连续跑 7 天,SOC 一路漂移,第三天就越限了。原因:日前调度只约束了单日 SOC 范围,没有约束日终 SOC 回到初始值。解决:在约束条件里加日终 SOC 偏差惩罚,或者把 SOC 初始值作为决策变量,让优化算法自己找合适的初始 SOC。我一般用后者,因为强制日终归零会限制储能利用率。

5.4 风光出力曲线归一化基准搞混,容量结果差一个数量级

现象:优化出来的风电装机是 2000 MW,但实际项目规划只有 200 MW。原因:归一化时除以了全年最大出力,而不是装机容量。如果原始数据是实际出力(MW),除以最大出力后,优化得到的“装机”其实是“最大出力倍数”,再乘以原始最大出力才是真实装机。解决:归一化前先确认原始数据的单位。如果是 MW,先除以已知装机容量得到出力系数;如果是出力系数,直接用。我一般在数据预处理阶段就打印均值和最大值,确认量级。

5.5 滚动优化窗口太长,实时性跟不上

现象:滚动优化每 15 分钟执行一次,但每次求解耗时 3 分钟,加上数据采集和指令下发,实际执行周期变成 20 分钟。原因:预测窗口horizon设了 24 个时段(6 小时),决策变量维度太高。解决:窗口缩短到 4 到 8 个时段,或者用简化模型做实时修正,比如只优化水电和储能的出力分配,风光出力直接按预测值走。我一般会在实时层用规则调度,滚动优化只用来修正规则调度的偏差。

6. 进阶技巧:用敏感性分析验证容量配置的鲁棒性

Pareto 前沿上的折中解是在特定风光出力和负荷曲线下得到的。实际运行中,风光资源年际波动、负荷增长、设备老化都会让实际运行点偏离设计点。所以最后一章不讲新算法,讲一个我每次做完优化都会做的动作:敏感性分析。

具体做法是:把折中解的容量配置固定,然后扰动风光出力曲线(±10%、±20%)、负荷曲线(±5%、±10%)、成本系数(±15%),重新跑调度仿真,看弃风弃光率和波动率的变化幅度。如果某个容量方案在 ±20% 扰动下指标恶化不超过 15%,我就认为它是鲁棒的。

def sensitivity_analysis(best_x, data, n_scenarios=100): """ 对折中解做敏感性分析,返回指标分布 """ results = [] for i in range(n_scenarios): # 随机扰动风光出力和负荷 wind_perturb = np.random.uniform(0.8, 1.2) pv_perturb = np.random.uniform(0.8, 1.2) load_perturb = np.random.uniform(0.9, 1.1) perturbed_data = data.copy() perturbed_data['wind_profile'] = data['wind_profile'] * wind_perturb perturbed_data['pv_profile'] = data['pv_profile'] * pv_perturb perturbed_data['load'] = data['load'] * load_perturb obj = objectives(best_x, perturbed_data) results.append(obj) results = np.array(results) print(f"弃风弃光率: 均值 {results[:,0].mean():.3f}, " f"标准差 {results[:,0].std():.3f}, " f"95分位 {np.percentile(results[:,0], 95):.3f}") print(f"波动率: 均值 {results[:,1].mean():.3f}, " f"标准差 {results[:,1].std():.3f}, " f"95分位 {np.percentile(results[:,1], 95):.3f}") return results

n_scenarios=100是扰动次数,每次独立随机采样。95 分位比均值更有参考价值,因为它告诉你“最差 5% 的情况有多差”。如果 95 分位的弃风弃光率超过考核阈值,这个容量方案就不能用,得回到 Pareto 前沿重新选点。

我自己的习惯是:优化跑完不算完,敏感性分析跑完才算完。有一次一个风电 800 MW、光伏 600 MW、水电 400 MW、储能 200 MW 的方案,Pareto 前沿上看着很美,弃风弃光率 4.8%,但敏感性分析 95 分位到了 11.2%,直接超过 10% 的考核线。后来把储能加到 300 MW,成本多了 8%,但 95 分位降到了 8.7%,这才敢往上报。容量配置这件事,宁可设计阶段多算几遍,也别等投运了再后悔。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进