ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于NSGA-II的水光互补优化调度Python实现与实战解析

基于NSGA-II的水光互补优化调度Python实现与实战解析 从第一个能跑的版本到最后能拿出来分析的结果我在这套水光互补优化调度代码上折腾了不少时间。一开始我以为把水电站出力和光伏出力拼在一起做个约束优化就行结果跑出来的调度方案自己都看不下去中午光伏在大发水库却在放水到了晚上负荷高峰水位已经掉到警戒线附近光伏又完全归零——这不是互补这是互相拖后腿。后来我把问题完整重构为多目标优化框架用非支配排序遗传算法NSGA-II求水光互补优化调度的Pareto解集并且全部用Python实现。本文就把这套模型构建、算法原理、代码实现和调试心得完整梳理一遍。适合正在接触新能源消纳、多目标优化问题或者想把NSGA-II落到实际电力调度场景里的朋友参考。我会把每个环节为什么这样做、哪里容易踩坑都讲清楚争取让你看完能直接照着搭一套。1. 为什么要做水光互补调度场景与症结1.1 光伏与水电的互补逻辑光伏出力的特点大家都很熟跟着光照走白天有、晚上没有一片云飘过来出力能瞬间掉一半以上。典型日曲线像个倒扣的碗中午达到峰值早晚接近零。水电则完全是另一个性格有库容调节能力的水电站可以按调度需求调整发电流量启动快、爬坡能力强但也受制于来水条件和水库库容的约束。两个电源放在一起天然存在时间互补性光伏中午大发时水电可以压低出力、存水傍晚光伏退坡后水电再顶上。这个逻辑说起来简单做成调度方案却不容易因为中间夹着库容约束、出力上下限、负荷跟踪要求等一系列限制。更麻烦的是光伏大发时段如果水电压得太低后续来水可能没地方存造成弃水如果水电发得太猛晚上又没有足够的水量去顶晚高峰。这就是水光互补调度真正的难点不是简单相加而是要在多个目标之间找平衡。我用的示例系统是一个小型日调节水库配光伏电站水电机组装机150MW、出力下限10MW光伏装机80MW。调度周期取24小时时间间隔1小时。这个规模足够展示算法效果又不至于让计算负担分散注意力。1.2 多目标到底在优化什么水光互补调度之所以要用多目标优化是因为运行人员关心的事情不止一件而且这些目标经常互相打架。我在项目里选了三个最核心的目标。第一个目标是弃电损失最小。光伏预测出力在那摆着因为系统消纳能力不足或者负荷太低实际没有发出来的光伏电量就是弃光水库水位超限被迫放掉的水就是弃水。这两个都意味着清洁能源浪费统一折到第一个目标里量纲都是能量单位。第二个目标是运行成本最小。水电机组虽然燃料不花钱但设备维护、耗水机会成本是实打实的通常可以用一个关于出力的二次函数来近似成本曲线。光伏成本可以简单认为是零但前提是它能被消纳。第三个目标是出力平稳性最优。水光联合出力应该尽量接近负荷需求同时水电的爬坡不能太剧烈。这个目标直接关系到电网的安全稳定运行我把它写成负荷跟踪偏差和爬坡惩罚的组合。这三个目标互相矛盾为了多消纳光伏水电在白天要少发但这样水库存水变多晚上可能被迫满发甚至弃水为了运行成本低水电倾向于在出力高效区工作但这不一定符合负荷曲线为了让出力平稳水电的调节频繁度又会上升。任何单一目标的最优解在另外两个目标眼里都可能很糟糕。所以最终输出的不应该是一个解而是一整条Pareto前沿让调度员根据当天实际工况去选。2. 优化模型构建目标函数与约束的落地2.1 三个目标函数与量纲处理调度周期T取24时段间隔Δt1小时。决策变量我定为两段拼接的向量前24维是水电机组各时段出力P_h(t)后24维是光伏各时段弃电比例k_curtail(t)取值0到1。光伏实际消纳出力为(1 - k_curtail(t)) * P_pv_pre(t)其中P_pv_pre是光伏预测出力。目标函数写成数学形式f1 Σ_t [k_curtail(t) * P_pv_pre(t) * Δt] λ * Σ_t Q_spill(t) * Δt第一项是弃光电量第二项是弃水流量折算惩罚。λ是弃水折电系数意义是把多少立方米弃水等价成一度电的损失。这部分参数需要根据水库实际情况标定。f2 Σ_t [a * P_h(t)^2 b * P_h(t) c] * Δt这是水电机组的运行成本函数系数a、b、c可以用历史运行数据拟合。光伏因为边际成本接近零消纳越多越划算所以成本目标天然会对光伏消纳产生正向引导。f3 Σ_t |P_h(t) P_pv_use(t) - P_load(t)| w * Σ_t |P_h(t1) - P_h(t)|第一项是联合出力对负荷曲线的绝对偏差总和第二项是水电相邻时段出力的变化总量w是爬坡惩罚权重。需要说明的是f3不一定要求联合出力严格等于负荷因为系统中通常还有火电或者其他电源参与平衡这里只关注水光这两个电源组合自身的行为质量。量纲上f1是电量MWhf2是费用元f3是功率偏差MW。这三个目标量纲完全不同数值范围也差很多。NSGA-II的好处恰恰是它不需要你把目标合成一个标量而是逐目标比较支配关系所以量纲差异不会影响排序逻辑。但是后面算拥挤度的时候要小心范围大的目标会占据主导地位我会在代码里对每个目标做归一化处理否则拥挤度指标会失真。2.2 约束条件与可行性判定约束条件分了四类每一类在代码里都有对应的处理方式。第一类是变量边界约束水电出力P_h(t)必须在[10, 150]MW内弃电比例k_curtail(t)必须在[0,1]内。这个最简单初始化时直接在边界内随机生成交叉变异后超出边界就裁剪回去。第二类是水库水量平衡。这是最关键的时序耦合约束水库时段末库容等于时段初库容加上入库径流减去发电流量减去弃水流量V(t1) V(t) I(t)*Δt - Q_turbine(t)*Δt - Q_spill(t)*Δt发电流量Q_turbine(t)由出力P_h(t)换算得到。假设水头恒定、机组综合效率恒定可以用简化公式P_h ηρgQ_turbineH。我按水头80m、效率0.85计算1MW出力大约需要1.5 m³/s的流量。第三类是库容约束V_min ≤ V(t) ≤ V_max。我把水库死库容设为1×10^6 m³正常高水位对应库容8×10^6 m³初始库容5×10^6 m³。这个库容对于150MW水电来说不算大属于典型的日调节水库刚好让库容约束在24小时调度里真正起作用。第四类是负荷平衡的松弛约束P_h(t) P_pv_use(t)可以不等于P_load(t)但偏差会反映在目标函数f3里。这种处理方式叫软约束不直接判死刑而是把偏差放进目标里让算法自己去权衡。约束处理我强烈建议用“模拟修复”而不是单纯罚函数。做法是对每个个体从V(0)开始顺着时间轴递推模拟水库运行一旦发现V(t)超过V_max就增加发电流量直到不越界或者达到机组最大出力如果发现V(t)低于V_min就减少发电流量直到不越界或者达到机组最小出力。修复完水量平衡后再计算三个目标函数。这样可以保证绝大多数个体是物理可行的Pareto前沿不会被一堆不可行解污染。3. NSGA-II核心原理解读与选型理由3.1 为什么选非支配排序遗传算法多目标优化的方法很多最朴素的是加权求和法给三个目标各定一个权重合成单目标后用传统遗传算法求解。我一开始也试过效果不好。问题出在权重怎么定成本目标数值上万负荷偏差只有几十权重差一个数量级结果就完全偏掉。更致命的是加权法一次只出一个解你根本不知道换个权重解会变成什么样相当于蒙着眼睛找方向。NSGA-II完全不同。它引入Pareto支配关系来判断两个解的优劣解A在所有目标上都不比解B差且至少在一个目标上严格优于B就说A支配B如果互有胜负就互不支配。整个算法最终输出的是一组互不支配的Pareto解覆盖不同偏好方向。运行人员可以看着前沿曲线根据当天是更在意消纳、更在意成本还是更在意平稳性选出最合适的一个解。以我的三个目标为例假设解A的f120MWh、f28000元、f335MW解B的f135MWh、f27500元、f330MW。A在f1上优于BB在f2和f3上优于A两者互不支配都有理由留在Pareto前沿上。这种比较方式对量纲不敏感也不需要人为设定权重实用得多。NSGA-II相比后来的NSGA-III、MOEA/D在目标数不超过三个时性价比很高代码量适中收敛速度也不错。所以我最终选它作为主算法而不是一上来就上更复杂的框架。3.2 拥挤度与精英保留策略的作用光有非支配排序还不够。排完序后种群被分成好几层第一层是当前最好的Pareto前沿。如果第一层的个体数量超过种群规模怎么选如果直接从前面按顺序拿很容易所有个体都挤在Pareto前沿的某一段失去多样性。NSGA-II用拥挤度解决这个问题。拥挤度的思路是看一个解在同一前沿内周围有多少其他解。计算方法是对每个目标单独排序相邻两个解的归一化目标值之差求和边界解拥挤度设为无穷大。拥挤度越大说明这个解周围越空保留它能维持解的分布均匀性让算法不断探索前沿的不同区域。精英保留策略也不复杂每一代把父代和子代合并成2N个个体先按非支配层从低到高挑选满了N就停。如果某一层会溢出就按拥挤度从大到小取。这样一来父代里的优秀个体不会因为随机变异而被丢掉算法的收敛性和多样性同时有保障。可以这么理解非支配排序是在给候选方案按“综合档次”分层拥挤度则是在同一档次里判断谁的位置更“孤僻”。保留孤僻的方案相当于让决策者能看到更多不同风格的可行方案。4. Python代码实现从伪代码到可运行工程4.1 数据组织与决策变量编码我把示例数据整理成一组numpy数组方便后续计算P_load: 24小时负荷序列单位MWP_pv_pre: 24小时光伏预测序列单位MWI: 24小时入库净流量序列单位m³/s水库参数: V0、Vmin、Vmax、水头H、效率eta决策变量编码用一个48维向量表示前24维是水电出力后24维是光伏弃电比例。上界和下界分别是n_t 24 lb np.array([10.0] * n_t [0.0] * n_t) # 水电出力下限10MW弃电比例下限0 ub np.array([150.0] * n_t [1.0] * n_t) # 水电出力上限150MW弃电比例上限1初始化种群用均匀随机采样def init_population(pop_size, lb, ub): return np.random.uniform(lowlb, highub, size(pop_size, len(lb)))为什么把弃电比例也作为决策变量而不是直接让光伏消纳由残差决定因为如果只给水电出力光伏消纳就成了被动的残差负荷偏差目标会被严重耦合限制算法很难找到多样化的折中方案。把弃电比例独立出来相当于给算法多一个自由度让它能够显式表达“宁愿弃光也不让水电过度调节”这类策略。4.2 关键函数实现详解目标函数计算是整个算法里最核心的部分。先做约束修复再递推水库最后返回三个目标值。def repair_and_evaluate(x, data, params): n_t data[P_load].shape[0] P_h np.clip(x[:n_t], params[P_h_min], params[P_h_max]) k_ct np.clip(x[n_t:], 0.0, 1.0) eta params[eta] rho 1000.0 g 9.81 H params[head] coef eta * rho * g * H / 1e6 # 转换为 MW per (m^3/s) V params[V0] P_h_fixed P_h.copy() Q_spill_total 0.0 for t in range(n_t): Q_turbine P_h_fixed[t] / coef V_next V data[I][t] * 3600 - Q_turbine * 3600 if V_next params[V_max]: # 增大发电流量尽量降低库容 max_Q params[P_h_max] / coef Q_turbine min(max_Q, (V data[I][t] * 3600 - params[V_min]) / 3600) P_h_fixed[t] Q_turbine * coef V_next V data[I][t] * 3600 - Q_turbine * 3600 if V_next params[V_max]: Q_spill (V_next - params[V_max]) / 3600 Q_spill_total max(0.0, Q_spill) V_next params[V_max] elif V_next params[V_min]: # 减少发电流量保住库容 min_Q params[P_h_min] / coef Q_turbine max(min_Q, (V data[I][t] * 3600 - params[V_max]) / 3600) P_h_fixed[t] Q_turbine * coef V_next V data[I][t] * 3600 - Q_turbine * 3600 V V_next P_pv_use (1.0 - k_ct) * data[P_pv_pre] # 目标1弃光 弃水折算 f1 np.sum(k_ct * data[P_pv_pre] * 1.0) params[lambda_spill] * Q_spill_total * 1.0 # 目标2运行成本 f2 np.sum(params[cost_a] * P_h_fixed**2 params[cost_b] * P_h_fixed params[cost_c]) * 1.0 # 目标3负荷偏差 爬坡 f3 np.mean(np.abs(P_h_fixed P_pv_use - data[P_load])) \ params[w_ramp] * np.sum(np.abs(np.diff(P_h_fixed))) return np.array([f1, f2, f3])这个修复逻辑里有几个容易出错的地方。一是单位换算发电流量Q的单位是m³/s乘3600才是每小时的水量库容单位是m³两边要一致。二是当库容超上限时我把目标定在让库容回落到V_min而不是V_max这看起来有点激进但实际是在给未来时段留出调节空间实测比回归到V_max效果更稳可以避免下一时段又立刻越界。非支配排序的实现比较直白但要注意复杂度是O(N²)。N100时无所谓如果N调到500以上纯Python循环就会拖慢速度后面我会讲优化办法。def non_dominated_sort(values): n values.shape[0] dominates np.zeros((n, n), dtypebool) for i in range(n): for j in range(n): if i j: continue # i 支配 j 当且仅当 i 所有目标不劣于 j且至少一个严格优于 j if np.all(values[i] values[j]) and np.any(values[i] values[j]): dominates[i][j] True n_dominated np.zeros(n, dtypeint) S [[] for _ in range(n)] for i in range(n): for j in range(n): if i j: continue if dominates[i][j]: S[i].append(j) elif dominates[j][i]: n_dominated[i] 1 front [[]] for i in range(n): if n_dominated[i] 0: front[0].append(i) k 0 while front[k]: next_front [] for i in front[k]: for j in S[i]: n_dominated[j] - 1 if n_dominated[j] 0: next_front.append(j) k 1 front.append(next_front) return front[:-1]拥挤度的计算要注意多目标数值范围差异大的问题。我在计算前先对每个目标做归一化把值压到[0,1]区间再算距离否则成本目标的数值范围会把其他目标的贡献淹没掉。def crowding_distance(values, indices): dist np.zeros(len(indices)) n_obj values.shape[1] norm values.max(axis0) - values.min(axis0) for m in range(n_obj): order sorted(indices, keylambda idx: values[idx, m]) dist[order[0]] np.inf dist[order[-1]] np.inf for idx in range(1, len(order) - 1): if norm[m] 1e-12: dist[order[idx]] (values[order[idx1], m] - values[order[idx-1], m]) / norm[m] return dist4.3 算法主流程串联NSGA-II的主流程其实不难难的是把每个环节的输入输出理顺。锦标赛选择、模拟二进制交叉、多项式变异这三个算子都有成熟的公式我直接给出可用的实现思路。锦标赛选择每次随机抽两个个体先比较非支配层级层级小的胜出如果层级相同拥挤度大的胜出。这样既能保证收敛压力又能照顾多样性。模拟二进制交叉SBX对两个父代个体逐维交叉子代保持在父代附近。分布指数ηc取20值越大子代越接近父代。多项式变异同理分布指数ηm取20。主循环如下def nsga2_water_photovoltaic(data, params, pop_size120, max_gen300): n_vars 2 * 24 lb np.array([10.0]*24 [0.0]*24) ub np.array([150.0]*24 [1.0]*24) pop init_population(pop_size, lb, ub) fitness np.array([repair_and_evaluate(ind, data, params) for ind in pop]) for gen in range(max_gen): fronts non_dominated_sort(fitness) rank np.zeros(pop_size, dtypeint) for r, front in enumerate(fronts): for idx in front: rank[idx] r crowd crowding_distance(fitness, list(range(pop_size))) # 锦标赛选择交配池 mating_pool [] for _ in range(pop_size): a, b np.random.choice(pop_size, 2, replaceFalse) if rank[a] rank[b]: mating_pool.append(a) elif rank[a] rank[b]: mating_pool.append(b) else: mating_pool.append(a if crowd[a] crowd[b] else b) # 交叉变异生成子代 offspring [] for i in range(0, pop_size, 2): p1, p2 pop[mating_pool[i]], pop[mating_pool[i1]] c1, c2 sbx_crossover(p1, p2, eta_c20) c1 polynomial_mutation(c1, lb, ub, eta_m20) c2 polynomial_mutation(c2, lb, ub, eta_m20) offspring.append(c1) offspring.append(c2) offspring np.array(offspring) fitness_off np.array([repair_and_evaluate(ind, data, params) for ind in offspring]) # 父代子代合并精英选择 combined_pop np.vstack([pop, offspring]) combined_fit np.vstack([fitness, fitness_off]) next_pop, next_fit select_elite(combined_pop, combined_fit, pop_size) pop, fitness next_pop, next_fit return pop, fitnessselect_elite内部就是非支配排序加拥挤度比较把2N个个体压回N个。这步是整个算法的关键也是NSGA-II相对简单遗传算法的核心优势所在。我在实际编码时遇到一个容易忽略的问题交叉变异后必须重新做约束修复而不是只在初始化和评估时修复。子代个体是在父代基础上组合出来的很可能产生边界外变量或者违反水量平衡的流量组合。如果省掉这步后面排序的质量直接崩塌。我当时在这个问题上吃过亏跑出来的第一前沿里混进不少物理上完全不可能的方案检查半天才发现是漏了子代修复。5. 运行效果与参数调试实录5.1 典型调度方案与Pareto前沿解读用上面的参数跑300代种群规模120最终得到的第一前沿大约有40到60个互不支配的调度方案。我随手列三个典型方案分别对应Pareto前沿的三个不同偏好方向方案弃电损失 f1 (MWh)运行成本 f2 (元)出力不平衡指标 f3 (MW)特点方案A18.2863041.5偏消纳弃电最少方案B33.7798022.8折中各目标均衡方案C47.5742012.6偏平稳成本较低负荷跟踪最好方案A明显是在光伏大发时段尽力压水电出力让光伏多上代价是晚上负荷高峰时水电满发甚至略有不足负荷偏差变大还可能因为白天少发导致水位偏高、晚上弃水风险增加。方案C则让水电保持在一个比较稳定的出力区间少做剧烈调整联合出力曲线更贴合负荷但付出的是更多弃光。从Pareto前沿上选最终方案我推荐一个简单实用的办法先把三个目标做归一化处理然后找距离原点最近的折中解也就是标准的TOPSIS思路。调度员还可以根据当天实际情况手动加权选择比如汛期多水弃水惩罚权重调高自然倾向方案A。我也画了每个方案的联合出力曲线和原始负荷曲线做对比。折中方案B的联合出力在中午时段会略高于负荷这是因为光伏大发期间刻意压低水电但没完全压死允许少量富余晚高峰时段水电接近满发联合出力基本贴住负荷。整体上比初始随机解平均负荷偏差低了一半以上效果肉眼可见。5.2 参数配置与收敛性判断经验NSGA-II参数里种群规模和迭代次数对结果影响最大。我建议第一轮先用种群120、迭代200代跑一遍观察Pareto前沿是否稳定。如果第一前沿的个体数始终很少或者折中解每个目标变化幅度还很大就加大到200代以上。交叉概率和变异概率同样关键。交叉概率我固定在0.9变异概率取1除以决策变量维度即约0.02。分布指数ηc和ηm都取20。特别提一下变异率太低的后果是种群多样性下降Pareto前沿会逐渐缩到一小块区域变异率太高又会让优秀个体被频繁破坏收敛变慢。我在一组对比实验里把变异率调到0.1结果300代后前沿的覆盖范围反而比0.02版本差很多。收敛性判断可以通过画收敛曲线实现。我每代记录第一前沿三个目标的平均值画成三条曲线。一般跑到150代以后曲线趋于平缓说明算法已经收敛。如果曲线还在明显波动优先增大概率种群而非迭代次数因为增加迭代次数对已经失去多样性的种群帮助不大。运行速度方面Python纯实现的NSGA-II在种群120、迭代300代时单次运行大约需要30到50秒主要瓶颈在非支配排序和修复函数里的时序递推循环。如果觉得慢有两个优化方向一是把目标函数和修复函数向量化去掉逐时段的for循环二是用numba的jit装饰器加速实测可以提速10到20倍代码改动很小。我后面项目就用了numba调度周期从24扩展到96小时也没有压力。6. 常见问题与排查技巧6.1 问题速查表运行过程中我整理了一张问题速查表基本覆盖了我自己踩过的坑症状可能原因解决方法初始种群几乎所有个体都是不可行解水量平衡修复逻辑不完整或者库容上下限设置不合理先单独测试一个随机个体打印逐时段库容和流量看哪一步越界Pareto前沿只集中在很小的区域种群太小多样性不足变异率太低增大种群到150以上变异率提高到1/维度附近第一前沿个体数很少不到5个目标函数之间存在强冗余或者某个目标数值异常检查三个目标是否真的相互冲突检查数据是否存在NaN收敛曲线波动剧烈交叉概率过高或选择压力过强把交叉概率降到0.85检查锦标赛选择是否退化成纯随机拥挤度计算异常某个目标所有个体取值相同导致归一化分母为零在拥挤度函数里对分母加极小值保护调度方案里库容总是贴着Vmax运行弃水惩罚权重太小水库没动力提前放水调大lambda_spill让弃水在目标函数里更“疼”运行时间过长非支配排序O(N²)循环加Python解释器开销用numba加速或者改用向量化矩阵比较6.2 避坑心得第一个心得不要迷信罚函数。水光调度里的水量平衡是时序耦合约束一个时段违规会传导到后面所有时段罚函数很难准确刻画这种累积影响。模拟修复虽然代码多一点但能让大部分个体物理可行Pareto前沿质量高得多。第二个心得目标函数之间的数值尺度差异必须正视。虽然NSGA-II的支配比较不受量纲影响但拥挤度计算会受影响。我在代码里对拥挤度做了归一化否则成本目标数值上万负荷偏差只有几十拥挤度基本被成本主导多样性维持效果形同虚设。第三个心得测试阶段不要只看最终Pareto前沿要看中间代。可以把第1代、第50代、第150代、第300代的Pareto前沿叠在一起画观察前沿如何向外推进。这比只看最终结果更容易判断算法是否卡在局部也方便确认是不是某个目标写错了。第四个心得负荷和光伏数据本身的不确定性不能忽略。这个项目用的是确定性预测曲线实际运行中光伏预测误差可能会到20%以上。我在后续扩展实验里把光伏预测误差用多场景法离散化每个场景带一个概率权重再做期望目标优化结果比确定性调度更稳健。这个思路可以作为项目的下一步扩展方向。最后再分享一个小技巧调试目标函数时先用几个手工构造的简单解验证方向。比如全时段水电出力固定在80MW、弃电比例全部为0手算一遍三个目标值再跑一下评估函数对比。如果对不上基本可以断定问题出在单位换算或者数组索引上跟算法本身没关系。这种从简单处入手的调试方法在复杂代码工程里能省下大把时间。
返回列表