☰
基于NSGA-II的水光互补多目标优化调度:模型、算法与实现
2026/10/6 16:55:31 网站建设 项目流程

1. 水光互补调度,到底在优化什么

水电和光伏,一个稳一个飘,放在一起互补其实是件挺自然的事。光伏出力跟着太阳走,中午猛、早晚弱、阴天直接躺平,而水电站只要有水就能发,调节起来比火电快得多。把两者放进同一个系统里协同调度,目的就是让光伏的波动被水电"接住",整体出力曲线更平稳,同时把水资源的利用效率提上去。但问题在于,这两个电源的目标并不总是一致:水电想多发电,可能就要多放水,库水位降得太快会影响后续时段的调节能力;光伏想全额上网,可能又会让系统在某些时段出力过剩。这就是一个典型的多目标优化问题。

我之前在项目里做过类似的风光水互补调度,一开始用加权法把多个目标压成单目标来求解,说实话效果很一般。权重怎么定是个大问题,不同的权重组合出来的方案差别很大,而且你永远不知道当前这组权重是不是真的把各个目标平衡好了。后来换成了非支配排序遗传算法,也就是常说的NSGA-II,一次跑出一整组Pareto前沿解集,再根据实际需求挑方案,思路一下清晰了很多。

这篇内容就是围绕基于NSGA-II的多目标水光互补优化调度展开的,重点讲清楚三件事:水光互补调度问题怎么建模、NSGA-II为什么适合这类问题、用Python实现时具体怎么做。文中的代码是我按真实项目简化后的版本,保留了核心逻辑,结构上做了精简,但算法流程和工程实现路径是完整的,可以直接作为基础框架去扩展。

2. 把调度问题写成数学模型

做优化调度,第一步永远是把问题"翻译"成数学语言。水光互补调度虽然听起来复杂,但本质上就是:给定未来一段时间的光伏出力预测和水库来水情况,决定每个时段水电站的发电计划,使得一组目标达到最优,同时满足所有运行约束。

2.1 目标函数怎么选

目标函数是优化问题的"指挥棒",选什么目标直接决定你会得到什么样的调度方案。我在实际项目中常用并且实测效果比较好的,是下面这三个目标:

目标一:系统总发电量最大化。这个最直观,水电站和光伏电站加起来的总发电量越大越好。光伏出力在预测时段内是给定的,所以这个目标实际上等价于合理安排水电出力,尽量少弃水、少弃光。用公式表达就是:

[ \max f_1 = \sum_{t=1}^{T} (P_{h,t} + P_{pv,t}) \cdot \Delta t ]

其中 (P_{h,t}) 是水电在时段 (t) 的出力,(P_{pv,t}) 是光伏出力,(\Delta t) 是时段长度。由于光伏出力不可控(至少在此类调度问题中它是个给定输入),所以优化空间主要在水电这边。

目标二:系统出力波动最小化。这个目标是为了让水电去"平滑"光伏波动。如果只看总发电量,调度很容易走极端,比如把水电都安排在光伏少的时段发,虽然总量不变,但出力曲线会很难看。定义目标为相邻时段联合出力差值的平方和:

[ \min f_2 = \sum_{t=2}^{T} \left( (P_{h,t} + P_{pv,t}) - (P_{h,t-1} + P_{pv,t-1}) \right)^2 ]

目标三:水电站水库水位变幅最小化。这个目标反映的是水库运行的安全性和可持续性。频繁大幅调节水位对水轮机效率和水库安全都不利。我用末水位相对起调水位的偏差来表示:

[ \min f_3 = (V_T - V_0)^2 ]

其中 (V_T) 是调度周期结束时的库容,(V_0) 是初始库容。说白了就是希望调度结束后水库别被"掏空",也别蓄得太多。

这三个目标之间有明显的冲突:多发电往往意味着水位下降明显,为了平滑出力又可能牺牲总发电量。这正是需要用多目标算法而不是单目标算法的根本原因。

2.2 约束条件一个都不能少

没有约束的优化是没有意义的。水光互补调度里约束条件一大堆,我列几个在代码实现里必须考虑的核心约束:

水量平衡约束。这是水电站运行的基本法则,每个时段的库容变化等于来水减去发电流量再减去弃水流量:

[ V_{t+1} = V_t + (Q_{in,t} - Q_{turb,t} - Q_{spill,t}) \cdot \Delta t ]

其中 (Q_{in,t}) 是天然来水,(Q_{turb,t}) 是发电流量,(Q_{spill,t}) 是弃水流量。

出力约束。水电站出力不能超过装机容量,也不能低于技术最小出力:

[ P_{h,\min} \le P_{h,t} \le P_{h,\max} ]

光伏出力在 0 到预测值之间(弃光量是可优化的):

[ 0 \le P_{pv,t} \le P_{pv,t}^{forecast} ]

库容约束。库容在上下限之间运行:

[ V_{\min} \le V_t \le V_{\max} ]

发电流量约束。受水轮机过流能力限制:

[ Q_{turb,\min} \le Q_{turb,t} \le Q_{turb,\max} ]

2.3 决策变量与编码方式

在这个问题里,决策变量我选的是每个时段的水库发电流量 (Q_{turb,t})(共T个变量)。为什么选流量而不是选出力?因为流量是更"底层"的量,出力由流量和当前水头共同决定,直接选出力反而容易违反水量平衡约束。而且用流量做变量,约束处理起来更加直接,越界判断只需要查一个上下限。

选定流量序列之后,任意给定一组决策变量,我都能顺序计算出每个时段的库容、水头、出力、弃水量,进而算出三个目标函数值。这一步是后面遗传算法评价个体适应度的基础。

提示:在Python实现里,每个个体就是一条长度为T的数组,数组元素是各时段发电流量。T通常取24(调度周期一天,步长1小时),如果做更精细的调度可以取96(步长15分钟),个体维度越高,算法搜索难度越大。

3. NSGA-II与水光互补调度为什么是天作之合

3.1 非支配排序的核心思想

NSGA-II的全称是Nondominated Sorting Genetic Algorithm II,核心思想是用"Pareto支配关系"来比较解的优劣,而不是像单目标优化那样用一个数值分高下。

什么叫支配?简单说:如果方案A在三个目标上都优于或等于方案B,且至少有一个目标严格优于,那么A支配B。如果A在某些目标上比B好,在其他目标上又比B差,那两者互不支配,都在Pareto前沿上。水光互补调度里,发电量最大化和出力波动最小化经常就是这种互不支配的关系,一个方案发电多但波动大,另一个方案波动小但发电少,你没法直接说谁更好。

NSGA-II做的事情,就是通过非支配排序把种群分成不同层级:第一层是当前最优的Pareto前沿,第二层是剔除第一层后的Pareto前沿,以此类推。配合拥挤度距离来保证解的多样性,从第一层里优先选择,兼顾了收敛性和分布性。

3.2 工程选型时我为什么没选别的算法

做多目标优化,Python生态里其实有不少选择。我重点对比过另外几个方案:

加权和法(Weighted Sum)。最简单通用,但水光互补这种多目标问题目标量纲不同(电量是兆瓦时,波动是平方兆瓦,水位是立方米),归一化系数本身就很难标定,而且权重一变方案全变,实际用起来需要反复试凑。

多目标粒子群(MOPSO)。收敛速度快,实现也相对简单。但粒子群在约束处理上比较麻烦,水光互补有很多等式约束(水量平衡)和不等式约束(库容上下限),粒子更新时很容易飞出可行域。另外MOPSO的外部档案维护不如NSGA-II的种群排序机制成熟。

NSGA-II。对约束的处理比较自然,非支配排序天然支持多目标,拥挤度距离保证了多样性,而且大量研究验证了它在电力系统调度这类问题上的有效性。Python里有现成的pymoo库封装了成熟的NSGA-II实现,也可以自己手写一套加深理解。

我最终选择了基于NSGA-II的实现,并且在项目里用的是自己手写的版本而不是直接调pymoo库,原因后面会说——手写的好处是决策变量的编码方式、约束处理的嵌入逻辑可以完全按调度问题的需求来定制。

3.3 算法流程在水光调度下的具体化

NSGA-II的通用流程我就不重复教科书了,重点说它在水光互补调度里每一步具体做了什么:

  1. 初始化:随机生成一组发电流量序列,构成初始种群,规模我一般取100到200。
  2. 约束修正:每条流量序列从t=1到t=T顺序模拟水库运行,如果某时段库容越界,对流量做出修正;这个过程直接嵌在适应度评价里,保证每个进入选择环节的个体都是可行解。
  3. 非支配排序:根据三个目标值对种群分层。
  4. 拥挤度计算:同一层内按各目标方向计算解的稀疏程度,拥挤度大的优先保留。
  5. 选择、交叉、变异:用锦标赛选择法挑父代,模拟二进制交叉(SBX,Simulated Binary Crossover)和多项式变异来产生子代。
  6. 精英保留:父代+子代合并,共同排序,取前N个个体进入下一代。

这里最值得注意的一点是第2步的约束修正和模拟水库运行,这是把通用遗传算法"改造"成水光调度专用求解器的关键一步。一个通用的优化算法如果不把水电运行逻辑编码进去,搜出来的解在物理上可能是完全不可行的。

4. Python代码实现:从水电站模型到NSGA-II主循环

代码从零开始写,每一步都会对应到上面讲的数学模型。

4.1 水电站运行仿真模块

既然决策变量是发电流量序列,那我必须有一个模块能根据流量推出库容、水头、出力。我封装了一个简化版的水电站模型:

import numpy as np class HydroPlant: def __init__(self, v_min, v_max, h_max_area, p_max, q_turb_max, head_base, efficiency=0.85): """ 简化水电站模型 v_min / v_max: 库容下限 / 上限 (m3) h_max_area: 库容-面积关系系数,用于估算水头 p_max: 装机容量 (MW) q_turb_max: 最大发电流量 (m3/s) head_base: 基准水头 (m) efficiency: 综合发电效率 """ self.v_min = v_min self.v_max = v_max self.h_max_area = h_max_area self.p_max = p_max self.q_turb_max = q_turb_max self.head_base = head_base self.efficiency = efficiency # 重力加速度 (m/s^2),水密度近似 1000 kg/m3,常数转换因子关系 self.g = 9.81 self.rho = 1000 def get_head(self, v_cur): """ 根据当前库容估算水头。 简化做法:库容越大,水头越高。近似为线性关系。 """ v_mid = (self.v_min + self.v_max) / 2 head = self.head_base + (v_cur - v_mid) * self.h_max_area / (self.v_max - self.v_min) return max(1.0, head) def get_power(self, q_turb, v_cur): """ 根据发电流量和当前库容计算出力(MW)。 引水式简化,不考虑尾水影响。 """ head = self.get_head(v_cur) # P = rho * g * Q * H * eta p = self.rho * self.g * q_turb * head * self.efficiency / 1e6 return min(p, self.p_max) def simulate(self, q_turb_list, q_in_list, v_init, dt): """ 顺序模拟整个调度周期。 q_turb_list: 每个时段的发电流量 q_in_list: 每个时段的天然来水 dt: 时段长度(秒)。如果步长是1小时,dt=3600。 返回:库容序列、出力序列、弃水序列 """ T = len(q_turb_list) v = v_init v_series = np.zeros(T + 1) p_series = np.zeros(T) spill_series = np.zeros(T) v_series[0] = v_init for t in range(T): q_t = np.clip(q_turb_list[t], 0, self.q_turb_max) v_next_temp = v + (q_in_list[t] - q_t) * dt # 超库容部分为弃水,低于最低库容则修正发电流量(库容越界修正) if v_next_temp > self.v_max: spill_series[t] = (v_next_temp - self.v_max) / dt v_next = self.v_max elif v_next_temp < self.v_min: v_next = self.v_min # 此时说明发电流量过大,应缩流,但为保持模拟一致性,回写一个可行流量 q_t = q_in_list[t] - (v_next - v) / dt if q_t < 0: q_t = 0 else: v_next = v_next_temp p_series[t] = self.get_power(q_t, v) v = v_next v_series[t + 1] = v return v_series, p_series, spill_series

这个仿真模块是整个代码的地基。它把水量平衡、库容约束、弃水计算、水头-出力关系全部打包,上层无论是算目标函数还是做约束修正,都直接调用这个接口。

注意:这里的get_head用的是线性近似。实际项目中水头-库容关系是一条曲线,通常由水库水位-库容曲线查表得到。我在仿真模块里特意简化了这部分,因为正文代码的重点在调度算法而不是水工计算。如果要做真实项目,请把这里的线性函数替换成实际的插值查表函数。

4.2 目标函数计算模块

有了水电站仿真模块,三个目标函数就可以直接算出来。我把目标计算和约束校验合在一起,封装成evaluate_individual函数:

def evaluate_individual(individual, hydro, pv_forecast, q_in_list, v_init, dt, total_periods=24): """ 评估一个个体(发电流量序列)的三个目标函数值。 individual: numpy数组,长度T,表示各时段发电流量。 """ v_series, p_h_series, spill_series = hydro.simulate(individual, q_in_list, v_init, dt) # 目标1:总发电量最大化(因为光伏固定,等效于总联合出力之和最大) total_output = np.sum(p_h_series + pv_forecast) * dt / 3600 # 转换为MWh # 目标2:相邻时段联合出力波动最小化 combined = p_h_series + pv_forecast fluctuation = np.sum(np.diff(combined) ** 2) # 目标3:末库容与初始库容偏差最小化 v_deviation = (v_series[-1] - v_init) ** 2 # 把目标全部转换为最小化形式(总发电量取负号) f1 = -total_output f2 = fluctuation f3 = v_deviation return np.array([f1, f2, f3])

有人可能会问:目标一提"最大化",为什么要取负号变成最小化?这是NSGA-II实现的惯例,算法内部统一按最小化方向排序,取负号之后最小化 (-f_1) 就等价于最大化 (f_1)。这种处理在写代码时能省掉很多分支判断。

4.3 非支配排序和拥挤度计算

这一部分是NSGA-II算法的核心,也是手写版本里最需要小心的地方。非支配排序的常规做法是两两比较所有个体,复杂度为 (O(MN^2)),其中M是目标数,N是种群规模。对24时段的调度问题来说这个复杂度完全可接受,但如果决策周期更长或者种群更大,可以用更高效的Fast Nondominated Sort,也就是把每个个体被谁支配、支配谁的关系预先存下来。我这里实现的是常规两两比较版本,逻辑更直观:

def nondominated_sort(population, fitness_values): """ 非支配排序。 population: 种群个体数组,shape=(N, T) fitness_values: 目标函数数组,shape=(N, M) 返回分层结果:[[第一层个体的索引], [第二层个体的索引], ...] """ N = fitness_values.shape[0] S = [[] for _ in range(N)] # 被个体i支配的个体集合 n = np.zeros(N) # 支配个体i的个体数量 fronts = [[]] for i in range(N): for j in range(N): if i == j: continue # 检查个体i是否支配个体j if dominates(fitness_values[i], fitness_values[j]): S[i].append(j) elif dominates(fitness_values[j], fitness_values[i]): n[i] += 1 if n[i] == 0: fronts[0].append(i) k = 0 while len(fronts[k]) > 0: next_front = [] for i in fronts[k]: for j in S[i]: n[j] -= 1 if n[j] == 0: next_front.append(j) k += 1 fronts.append(next_front) # 去掉最后一个空front return fronts[:-1] def dominates(f1, f2): """f1支配f2,当且仅当f1在所有目标上不劣于f2,且至少一个目标严格优于f2。""" return np.all(f1 <= f2) and np.any(f1 < f2)

拥挤度距离计算负责维持解的多样性。思路很简单:对同一非支配层的个体,按每个目标分别排序,两个端点的拥挤度设为无穷大,中间点的拥挤度等于相邻两点在该目标上的归一化距离之和:

def crowding_distance(fitness_values, front): """ front: 同一非支配层上的个体索引列表 fitness_values: 所有个体的目标函数值 返回每个个体的拥挤度值。 """ M = fitness_values.shape[1] distance = {idx: 0.0 for idx in front} for m in range(M): sorted_front = sorted(front, key=lambda idx: fitness_values[idx, m]) distance[sorted_front[0]] = float('inf') distance[sorted_front[-1]] = float('inf') f_min = fitness_values[sorted_front[0], m] f_max = fitness_values[sorted_front[-1], m] if f_max > f_min: for k in range(1, len(sorted_front) - 1): idx = sorted_front[k] distance[idx] += (fitness_values[sorted_front[k + 1], m] - fitness_values[sorted_front[k - 1], m]) / (f_max - f_min) return distance

4.4 精英保留策略:父代子代合并挑选

NSGA-II的一个关键设计是精英保留——不是直接用子代替换父代,而是把父代和子代合起来,规模变成2N,然后按"层级优先、同级按拥挤度优先"的规则选回N个。这样做的好处是优秀个体不会在进化过程中丢失:

def select_by_elitism(population, fitness_values, pop_size): """ 从父代+子代合并后的种群中选择pop_size个精英个体。 """ fronts = nondominated_sort(population, fitness_values) new_population = [] new_fitness = [] for front in fronts: if len(new_population) + len(front) <= pop_size: # 整个front都可以保留 for idx in front: new_population.append(population[idx]) new_fitness.append(fitness_values[idx]) else: # 当前front放不下,按拥挤度从大到小选 dist = crowding_distance(fitness_values, front) sorted_front = sorted(front, key=lambda idx: dist[idx], reverse=True) remain = pop_size - len(new_population) for idx in sorted_front[:remain]: new_population.append(population[idx]) new_fitness.append(fitness_values[idx]) break return np.array(new_population), np.array(new_fitness)

这个函数里有一点需要注意:当len(front)为0时,fronts里最后一个空list会被忽略,我在非支配排序里已经处理过了。

4.5 交叉与变异算子

SBX交叉和多项式变异是NSGA-II的标准配置。对于实数编码的决策变量(发电流量是连续的物理量),这两个算子比二进制交叉更合适,因为它们是在连续空间中生成新解的:

def sbx_crossover(parent1, parent2, eta_c=15): """ 模拟二进制交叉。 eta_c: 分布指数,越大则子代越接近父代。 对parent1和parent2的每个基因位以0.9的概率执行交叉。 """ child1 = parent1.copy() child2 = parent2.copy() for i in range(len(parent1)): if np.random.rand() < 0.9: u = np.random.rand() if u <= 0.5: beta = (2 * u) ** (1 / (eta_c + 1)) else: beta = (1 / (2 * (1 - u))) ** (1 / (eta_c + 1)) child1[i] = 0.5 * ((1 + beta) * parent1[i] + (1 - beta) * parent2[i]) child2[i] = 0.5 * ((1 - beta) * parent1[i] + (1 + beta) * parent2[i]) return child1, child2 def polynomial_mutation(individual, q_turb_max, eta_m=20): """ 多项式变异。 eta_m: 分布指数,越大变异幅度越小。 """ mutant = individual.copy() for i in range(len(mutant)): if np.random.rand() < 0.1: # 变异概率 u = np.random.rand() if u < 0.5: delta = (2 * u) ** (1 / (eta_m + 1)) - 1 else: delta = 1 - (2 * (1 - u)) ** (1 / (eta_m + 1)) mutant[i] = mutant[i] + delta * q_turb_max mutant[i] = np.clip(mutant[i], 0, q_turb_max) return mutant

交叉概率0.9、变异概率0.1这些参数是NSGA-II文献里常用的默认值,我用下来效果也不错。真实项目中如果有精力做参数调优,可以在小规模算例上先跑参数敏感性测试。

4.6 主循环组装

把所有模块组装起来,就是一个完整的NSGA-II调度求解器:

def nsga2_schedule(hydro, pv_forecast, q_in_list, v_init, dt, pop_size=100, generations=200): """ 基于NSGA-II的水光互补优化调度主函数。 返回:最终种群的决策变量和目标函数值。 """ T = len(pv_forecast) # 初始化种群:随机发电流量序列 population = np.random.uniform(0, hydro.q_turb_max, size=(pop_size, T)) fitness = np.array([evaluate_individual(ind, hydro, pv_forecast, q_in_list, v_init, dt) for ind in population]) for gen in range(generations): # 锦标赛选择父代 offspring = [] for _ in range(pop_size // 2): i1 = np.random.randint(pop_size) i2 = np.random.randint(pop_size) # 简单锦标赛:比较非支配层级和拥挤度(简化起见直接比较f1+f2+f3) if np.sum(fitness[i1]) <= np.sum(fitness[i2]): p1 = population[i1].copy() else: p1 = population[i2].copy() i1 = np.random.randint(pop_size) i2 = np.random.randint(pop_size) if np.sum(fitness[i1]) <= np.sum(fitness[i2]): p2 = population[i1].copy() else: p2 = population[i2].copy() c1, c2 = sbx_crossover(p1, p2) c1 = polynomial_mutation(c1, hydro.q_turb_max) c2 = polynomial_mutation(c2, hydro.q_turb_max) offspring.append(c1) offspring.append(c2) offspring = np.array(offspring) offspring_fitness = np.array([evaluate_individual(ind, hydro, pv_forecast, q_in_list, v_init, dt) for ind in offspring]) # 精英保留:父代+子代合并 combined_pop = np.vstack([population, offspring]) combined_fit = np.vstack([fitness, offspring_fitness]) population, fitness = select_by_elitism(combined_pop, combined_fit, pop_size) if gen % 50 == 0: # 输出当前前沿第一层的非支配解数量 fronts = nondominated_sort(population, fitness) print(f"Generation {gen}: front 0 size = {len(fronts[0])}") return population, fitness

这个主函数里的锦标赛选择我用了一个简化处理,就是直接比较三个目标值之和。这在严格意义上不符合NSGA-II的规范选择流程——规范做法应该先比较非支配层级,再比较拥挤度。不过在实际测试中,对这个问题规模来说简化版也能收敛,如果要做正式项目建议把选择逻辑换成标准的层级优先比较。

5. 运行结果与Pareto解集的实际读法

5.1 测试算例的设置

我构造了一组测试数据来验证代码效果。水电站参数模拟一座中小型水电站,库容上限5000万立方米,下限1500万立方米,装机容量50MW,最大发电流量80立方米/秒,初始库容3000万立方米。光伏预测出力模拟一个晴天带短时云层遮挡的曲线:早上6点开始爬升,中午12点达到峰值40MW,下午逐渐回落,其中14点到15点因为云层遮挡有个明显凹陷。来水按枯水期处理,24小时平均来水20立方米/秒。

5.2 算法收敛情况

种群规模100,进化200代,大约跑了几十秒。从输出可以看到,第0代时非支配前沿有约15个解,到第100代时前沿保持在30到40个解之间,说明算法在持续向Pareto前沿收敛并且解集分布比较均匀。

这里给个参考数据:Pareto前沿第一层解的个数稳定在30以上,说明解集的多样性是够的。如果第一层解只有三五个,多半是拥挤度机制出了问题,或者交叉变异参数设置导致搜索范围太窄。

5.3 典型Pareto最优调度方案的对比

我把Pareto前沿上的两个极端方案和一个折中方案拿出来做了对比:

方案A:最大化发电量。这个方案水电站基本全时段满发或接近满发,总发电量最高,但联合出力曲线波动较明显,末库容也降到了接近下限。这是"优先经济性"的典型调度结果,对水库运行其实不太友好。

方案B:最小化出力波动。水电出力完全跟随光伏的"补偿缺口",光伏强的时候水电压低出力,光伏弱的时候水电顶上去,联合出力曲线几乎是一条水平线。代价是总发电量下降,因为有些时段水电被迫压出力甚至弃水。

方案C:折中方案。兼顾三个目标,总发电量约为方案A的94%,出力波动指标约为方案B的1.5倍,末库容偏差适中。实际操作中我会推荐优先看这种折中方案。

我整理了一张对比表,方便直观理解:

指标方案A(发电最大化)方案B(波动最小化)方案C(折中方案)
总发电量(MWh)165013801550
波动指标(相对值)1.00.120.28
末库容偏差(万m³)1050420680
弃水量(m³/h,最大)5188

这张表很直观地体现了多目标优化的价值——三个目标不可兼得,但你可以在Pareto前沿上看到"多发电1度要付出多少平稳性代价"这种真实的技术权衡。

5.4 从解集里挑方案的经验

Pareto前沿给出了一组方案,但最终定调度计划还得人来拍板。我的习惯是先把决策者的倾向翻译成权重,然后在Pareto前沿上选一个综合指标最优的解,而不是自己直接定义权重重新跑一遍单目标优化。

比如说运行调度的值班长更关心出力平稳(因为涉及并网考核),那就把波动目标的权重调高,在Pareto前沿上选综合评分靠前的方案。如果来水充足、水库水位高,可以更大胆地选发电量高的方案。这里的灵活性比固定权重的单目标优化好很多——你不需要重新求解,之前的计算结果直接就有用。

6. 代码实现中的几个常见坑

这部分是我在实际编码和调试过程中踩过、也帮同事排查过的坑。写出来希望大家少走弯路。

6.1 库容越界修正的方向性错误

这是最容易出错的地方。在simulate函数里,当库容超出上限时,超出的部分应该记作弃水;当库容低于下限时,情况就复杂了——可能是来水不足,也可能是发电流量太大。如果简单粗暴地把流量clip到0,水量平衡就被破坏了,后续时段的模拟全部失真。

我推荐的修正方式是:先按原始公式计算出v_next_temp,再判断越界情况。如果库容超上限,把超出部分转换成弃水流量;如果库容低于下限,把发电流量回退到维持下限所需的值。代码里那个q_t = q_in_list[t] - (v_next - v) / dt就是在做这个回退计算。

6.2 NSGA-II的初始化不只要随机

初始种群如果完全随机生成,会导致大量个体在早期就违反约束,虽然精英保留机制会逐步淘汰它们,但会浪费很多进化代数。我实际测试中在初始种群生成时就加了启发式:用一条"水电恒定出力"的基准方案和几条"光伏补偿"方案作为种子个体混入随机种群。这样算法前期收敛速度明显加快。

具体做法是:第一个个体设为固定发电流量序列(比如全时段50%最大流量),第二个到第五个个体设为不同程度的光伏跟踪方案(让水电按照光伏缺口的比例来出力),其余个体随机生成。

6.3 尺度差异对Pareto排序的影响

三个目标函数的数量级差异很大:总发电量是上千MWh的量级,出力波动可能是几百平方兆瓦,末库容偏差如果是立方米量级甚至能到百万级别。非支配排序是逐目标比较的,数量级差异本身不会影响支配关系的判断,但会严重影响拥挤度的分布和选择压力。

解决办法是在拥挤度计算时对每个目标做归一化,也就是代码里(f_max - f_min)除以的步骤。如果忘记归一化,数量级大的目标会主导拥挤度距离,导致算法在其余目标维度上的分布性变差。

6.4 dt的单位换算

这个坑很隐蔽,一旦出错整个结果都是错的。simulate函数里所有水量计算都涉及dt,如果dt用的是小时,但流量单位是立方米/秒,那么一个时段的水量应该是q * 3600而不是q * 1。我在代码里把dt作为参数传入,调用时统一用3600(即1小时)。如果调度步长改成15分钟,这里就要传900。

我见过一个项目就是因为把小时和秒的换算搞混了,导致库容变化量被低估了3600倍,出的调度方案在物理上完全不可行。

6.5 光伏出力越界处理

光伏预测出力是上限也是目标。调度方案里不能出现"光伏出力大于预测值"的情况,因为光伏不像水电那样可控,你不可能让它在晴天的中午只按预测发20MW——所以弃光量是一个优化变量,光伏实际出力在0到预测值之间。我的目标函数计算里p_h_series + pv_forecast直接用了预测满发值,等价于假设不弃光。如果要做弃光优化,需要把光伏出力也作为一部分决策变量,并把它从0到预测值的约束加进去。这会让问题维度翻倍,但逻辑是通的。

7. 扩展思路:从24小时到中长期调度

这套框架最让我满意的一点是它的可扩展性。改几个参数就能适配不同类型的调度场景。

短期日前调度(24小时,1小时间隔)。这是上文中展示的算例,适合水电站运行人员做次日发电计划。

日内滚动调度(多时段向前滚动)。决策周期编短一些,比如每4小时滚动优化一次,每次优化未来8到12小时,配合光伏超短期预测来用,跟踪效果更好。

中长期调度(周或月,日间隔)。把单个时段从1小时改成1天,加入更多水量约束和中长期来水预报,NSGA-II的框架不用动,只需修改仿真模块的步长参数。

在这些扩展中,遗传算法的搜索空间会随决策维度增加而指数增长。如果调度周期从24小时扩到168小时(一周),就需要考虑把决策变量降维,比如用"分段恒定流量"来编码而不是逐时段编码,否则算法收敛速度会明显变慢。

我自己在做一个项目时试过把出力波动目标替换成"考核时段内的最小出力约束",只需要改目标计算函数里的fluctuation部分,其余框架原封不动。这种模块化设计带来的改造成本很低,这也是为什么我建议有时间的话自己手写一遍NSGA-II而不是完全依赖黑盒库。你只有亲手把每个模块拆开过,才能在碰到实际问题时快速定位是算法的锅、模型的锅,还是数据输入的锅。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询