去年帮某园区厂房屋顶做光伏布置测算,业主丢来一张卫星图、一份结构检测报告,再加一句“两周内给我一版发电量尽量高的排布方案”。以前遇到这种活儿,我都是开光伏仿真软件手动拖面板、调间距,屋顶稍微不规则就得反复试,一版方案折腾大半天。那次屋顶形状还算规整,但既要预留检修通道,又要避开气楼和女儿墙,约束一多,手动排布明显不划算了。后来我把问题转成一个可计算的优化模型,用改进型微分进化(DE)算法自动搜索面板的位置、倾角和朝向,总算把周期压了下来。这篇博文把整条思路、模型设计和完整代码展开讲讲,适合正在做分布式光伏设计、新能源项目可行性研究,或者学了智能优化算法想找个真实场景落地的朋友。
先说明一下,我不会把代码直接甩给你就完事。这篇文章的重点是“为什么这么做”:为什么光伏布局要转成优化问题,为什么选微分进化而不是粒子群或遗传算法,标准DE有什么毛病,我针对它做了什么改进,最终代码长什么样、跑出来什么效果。每一步都放在实际场景里讲,这样你拿过去改一改,就能用到自己的屋顶项目上。
1. 从屋顶排布到优化问题:为什么这个场景绕不开智能算法
1.1 光伏布局优化的决策变量、约束和目标
分布式光伏布局优化,说白了就是回答一个问题:屋顶上那么多位置,每块光伏板到底放在哪、以什么倾角放、朝哪个方向放,才能让发电量最高。
这里的决策变量分成三类。
位置坐标。每块板在屋顶平面上的位置,通常用二维坐标表示。屋顶往往是矩形加缺角,或者有凸起物,位置一变,阴影关系就全变了。这是最核心也最容易出问题的一类变量。
安装倾角。固定支架的光伏板有一个安装倾角,通常针对项目所在地纬度取一个最佳倾角。但实际屋顶可能有坡度、朝向限制,不能所有板都按最佳倾角放,所以倾角本身也作为一个连续决策变量处理。
方位角。光伏板朝向正南通常发电最好(北半球),但屋顶形状可能迫使某些板往东或往西偏。方位角偏离正南多少,直接影响发电量。
约束条件就更多了。屋顶边界是硬约束,板不能超出可用区域;光伏板之间存在最小间距要求,否则前排阴影会遮挡后排,形成发电损失;屋顶上还有气楼、排烟管、女儿墙等障碍物,必须绕开;运维上还要留检修通道;如果接入组串式逆变器,组串之间的电压匹配也会反过来限制板的数量和分布。
目标就是发电量最大化。但工程上往往还要兼顾投资收益率、阴影损失最小化,甚至美观度。多目标问题后期可以扩展,我先从单目标说起。
这些变量和约束叠在一起,组合空间大得离谱。一个小屋顶放30块板,位置有上百个可选点位,倾角和方位角又是连续值,穷举根本不可能。做过设计的人都知道,手动排布非常依赖经验,每次调整都需要重新测算阴影,改一版要重算一遍。算法优化的意义就在这里:让计算机自动搜索一个接近最优的排布方案,人只需要负责把约束条件写清楚、把目标函数定义准确。
1.2 DE vs PSO vs GA:我为什么选了微分进化
智能优化算法里,最常用的是遗传算法(GA)、粒子群算法(PSO)和微分进化算法(DE)。我都实际用过,最后在这个项目里选了DE做基础,不是因为它完美,而是它在“代码量、稳定性、可改进空间”三者的平衡最好。
遗传算法的核心问题是编码和算子设计。实数向量用于GA时,需要额外设计模拟二进制交叉、多项式变异这些算子,代码复杂度高。而且GA的参数不少:交叉概率、变异概率、锦标赛规模、精英数量,调参周期长。我项目周期只有两周,没那么多时间磨参数。
粒子群算法的问题在于早熟收敛。PSO的粒子会被群体最优吸引,收敛速度快是真的,但如果早期群体最优落在局部极值附近,整个群体大概率会慢慢聚过去,后面很难跳出来。光伏布局这个问题的目标函数,因为存在阴影惩罚和越界惩罚,很容易出现大量局部极值点,PSO并不理想。
微分进化的逻辑很直接:变异向量由种群中三个随机个体的差分产生,相当于把“种群分布信息”直接编码进搜索方向里。它天然适合实数向量,不需要额外的编码解码,参数也只有三个核心的:种群规模NP、变异因子F、交叉概率CR。代码实现非常顺手。
更关键的是,DE的改进空间很大。标准DE在收敛中后期种群多样性快速下降,这个问题可以通过自适应参数、柯西扰动等手段修正。这正好对应博文标题里的“改进型”三个字。所以我最后选了DE作为主算法,改造成适合光伏布局问题的版本。
2. 先把经典DE算法拆开:流程、代码与两点不足
2.1 变异、交叉、选择、边界处理
标准DE算法的流程非常清晰,就四个步骤。
初始化种群:在决策变量上下界内随机生成NP个D维实数向量,每个向量对应一个候选解。
变异:对种群中每个个体,从种群中随机选择三个互不相同的个体r1、r2、r3,按公式生成变异向量:
v = x_r1 + F * (x_r2 - x_r3)
这里的F就是变异缩放因子,控制差分向量的放大程度。差分向量本身反映的是当前种群中不同解之间的分布信息,F越大,探索范围越大。
交叉:把变异向量与原个体按交叉概率CR混合,生成试验向量。为了让试验向量不完全等于原个体,至少保证一个维度来自变异向量。
选择:计算试验向量的目标函数值,如果优于原个体就替换,否则保留原个体。贪心策略保证了每一代种群不会变差。
边界处理:如果变异或交叉产生的新个体越界了,最简单的做法就是直接截断到边界值,或者反射回边界内部。标准DE通常用截断,稳定且开销小。
具体代码长这样:
import numpy as np def rastrigin(x): A = 10 n = len(x) return A * n + np.sum(x**2 - A * np.cos(2 * np.pi * x)) def de_standard(obj_func, dim, bounds, NP=50, F=0.8, CR=0.9, max_iter=500): lb = np.array([b[0] for b in bounds]) ub = np.array([b[1] for b in bounds]) pop = np.random.uniform(lb, ub, size=(NP, dim)) fitness = np.array([obj_func(ind) for ind in pop]) best_idx = np.argmin(fitness) best_x = pop[best_idx].copy() best_f = fitness[best_idx] history = [best_f] for _ in range(max_iter): for i in range(NP): idxs = [j for j in range(NP) if j != i] r1, r2, r3 = np.random.choice(idxs, 3, replace=False) v = pop[r1] + F * (pop[r2] - pop[r3]) v = np.clip(v, lb, ub) jrand = np.random.randint(dim) u = np.zeros_like(pop[i]) for j in range(dim): u[j] = v[j] if (np.random.rand() < CR or j == jrand) else pop[i][j] u_f = obj_func(u) if u_f < fitness[i]: pop[i] = u fitness[i] = u_f if u_f < best_f: best_f = u_f best_x = u.copy() history.append(best_f) return best_x, best_f, history2.2 先跑一个Rastrigin:经典DE暴露出的两个问题
拿这套标准DE去跑Rastrigin函数,维度设20,变量范围[-5.12, 5.12]。这个函数多峰、局部极小值很多,适合考验算法的跳出能力。
跑出来的结果是:在迭代初期,DE收敛速度很快,大概30到50代就能找到一个明显优于初始点的区域。但到了中后期,问题就显现出来了。
第一个问题是固定F带来的矛盾。F设为0.8,前期探索范围够大,但到了后期所有个体都聚在最优解附近,差分向量x_r2 - x_r3的长度已经变得非常小,乘上0.8也只是一个微小的扰动。如果最优解周围地形崎岖,算法就只能在小范围里慢慢挪,很容易被卡在局部极值。把F调小到0.4,前期探索又会乏力,搜索范围不足。
第二个问题是种群多样性的快速流失。DE的选择策略是贪心替换,每一代都在淘汰适应度较差的个体。到后期,种群里的个体会越来越像,差异向量趋近于零向量,变异操作基本失效。这时候整个算法就退化成一群在相同位置附近微调的个体,已经失去了“全局搜索”的意义。
这两个问题在Rastrigin上已经很明显,换到光伏布局这种带约束、多局部极值、维度更高的场景会更严重。这也是我决定对DE做针对性改进的根本原因。
3. 三处关键改进:自适应参数、柯西扰动与约束处理
3.1 自适应变异因子F和交叉概率CR
既然固定F不行,那就让F随迭代过程动态变化。
迭代早期,种群还在全局探索阶段,需要较大的F来保持差分向量的扩散能力;迭代后期,个体逐渐聚向最优区域,需要较小的F来做精细搜索。用一个简单的指数衰减曲线就能实现:
F = F_min + (F_max - F_min) * (1 - t)^2
其中t = 当前迭代次数 / 总迭代次数。迭代刚开始时t接近0,F接近F_max;迭代快结束时t接近1,F接近F_min。我给F_max取0.9,F_min取0.4,这样前期能大步探索,后期能精细收敛。
交叉概率CR我做了反向的自适应。前期CR稍低,让变异向量对个体的改造温和一些,保留种群中已有的好信息;后期CR升高,使试验向量更多来自变异结果,增强局部搜索的细腻度。具体取法是:
CR = CR_min + (CR_max - CR_min) * t
CR_min取0.6,CR_max取0.95。这里的思路是:前期的差分变异幅度本来就大,交叉太激进容易破坏有效结构;后期变异幅度小,交叉更激进反而能加快收敛。
3.2 柯西扰动:给最优个体加一针强心剂
标准DE后期最大的麻烦是种群多样性流失。我加了一个柯西扰动项,在变异向量生成之后,叠加一个以当前全局最优个体为中心的随机扰动:
v = v + scale * cauchy_random * (best_x - v)
这里有两个细节。随机数用的是标准柯西分布而不是高斯分布。柯西分布尾部更厚,简单说就是它更容易产生偏离中心较远的随机值。在优化算法里,这个“长尾巴”意味着有更大的概率一下跳到离当前区域较远的位置,从而跳出局部极值。高斯分布产生的扰动大多集中在均值附近,跳出能力弱很多。
另一个细节是scale要随时间衰减。我用scale = 0.05 * (1 - t),迭代前期扰动强度大,帮助搜索远离当前区域;后期扰动幅度小,避免破坏已经收敛的精细解。
这个改进的效果非常直观:迭代到后半段,标准DE的种群基本固化,而加入柯西扰动后,即使所有个体都聚在一起,每次变异仍然有一定概率产生一个偏离当前位置的试验向量,等于给算法装了一个“随时跳出去看看”的保险机制。
3.3 罚函数、边界反射与取整技巧
光伏布局问题带约束,我处理约束的方法是用罚函数。
思路很简单:把约束不满足的程度量化成一个惩罚值,加到目标函数里。比如光伏板越界了,就按越界距离乘以一个惩罚系数;两板间距小于最小间距,就按欠缺的距离乘以另一个系数。目标函数变成:
总目标 = 目标值 + 约束惩罚
这样就把有约束优化问题转成了无约束问题,DE不需要额外改动就能处理。
罚函数系数的大小需要经验。惩罚系数太小,算法会发现“越界多发电量也划算”,解会跑出可用区域;惩罚系数太大,算法一开始就会被约束死死压住,连探索的余地都没有。我的做法是先不设定惩罚,跑50代,看各类约束被违反的数量级,再把惩罚系数调到刚好比正常目标函数值高一个数量级。这个阈值在后面的代码里能直接看到。
边界反射和整数变量处理也有技巧。边界反射比直接截断好一点:截断会把所有越界分量都压到边界值上,导致大量个体挤在边界附近,多样性下降;反射则是把越界分量从边界往内部弹回。代码里我用np.clip做截断,是为了简单稳定,实际项目中如果你想更精细,可以改成反射逻辑。
离散变量,比如光伏板的组串编号、安装点位的网格索引,处理原则是“先连续、后取整”。不要在搜索过程中频繁取整,取整会让决策空间变成非连续阶梯,差分向量会失去梯度方向的意义。正确做法是让DE在连续空间搜索,最后输出方案时才把整数维取整。
4. 完整实战:光伏布局优化模型的建模与IDE代码实现
4.1 从工程问题到目标函数:三层惩罚模型
为了把算法真正跑起来,我把光伏布局问题做了一层简化建模。这个简化不是拍脑袋,而是刻意为之:先把算法逻辑跑通,后续再接专业辐照仿真软件做精细复算。
场景参数如下。屋顶可用区域设为20米长、12米宽,共12块光伏板,每块板长度1.6米、宽度1.0米。每块板的决策变量是4个:x坐标、y坐标、倾角tilt、方位角azimuth。12块板加起来就是48维决策向量。
目标函数分三层。
第一层是辐射项。理想的辐射模型需要当地纬度、日期、时刻、云量等数据,这里我用一个近似公式替代:倾角越接近当地最佳倾角(假设30度),发电量越高;方位角越接近正南(0度),发电量越高。用一个二维高斯型函数把这个关系表达出来,就会得到一个介于0到1之间的辐射系数,所有板的辐射系数求和就是总目标。
第二层是阴影惩罚。两块板的中心距离如果小于最小间距(设为3米),就认为存在遮挡风险,按“3米减实际距离”乘以系数0.8加进惩罚。这个模型把复杂的日照阴影关系简化为“距离够不够远”,虽然粗糙,但对算法验证已经足够。
第三层是边界惩罚。每块板的中心点必须离屋顶边缘至少半个板长(x方向0.8米)、半个板宽(y方向0.5米)。超出部分按距离乘以系数10加进惩罚。
整体目标函数是最小化“负辐射项加惩罚项”。优化完成后,适应度值越小,代表发电量越高、阴影和越界越少。
4.2 可直接运行的IDE完整代码
下面给出完整代码。依赖只需要NumPy,直接复制到Python环境就能跑。
import numpy as np # 光伏布局目标函数 def pv_objective(x): # x: 一维向量,每4个变量对应一块板的 x, y, tilt, azimuth n_panels = len(x) // 4 panels = x.reshape(n_panels, 4) area_x, area_y = 20.0, 12.0 panel_len, panel_wid = 1.6, 1.0 min_gap = 3.0 rad_sum = 0.0 shadow_penalty = 0.0 bound_penalty = 0.0 for p in panels: xc, yc, tilt, azim = p rad = 0.8 + 0.2 * np.exp(-((tilt - 30) / 20) ** 2) * np.exp(-(azim / 30) ** 2) rad_sum += rad if xc < panel_len / 2: bound_penalty += (panel_len / 2 - xc) * 10 if xc > area_x - panel_len / 2: bound_penalty += (xc - (area_x - panel_len / 2)) * 10 if yc < panel_wid / 2: bound_penalty += (panel_wid / 2 - yc) * 10 if yc > area_y - panel_wid / 2: bound_penalty += (yc - (area_y - panel_wid / 2)) * 10 for i in range(n_panels): for j in range(i + 1, n_panels): xi, yi = panels[i][0], panels[i][1] xj, yj = panels[j][0], panels[j][1] dist = np.sqrt((xi - xj) ** 2 + (yi - yj) ** 2) if dist < min_gap: shadow_penalty += (min_gap - dist) * 0.8 return -rad_sum + shadow_penalty + bound_penalty # 改进型微分进化算法 class ImprovedDE: def __init__(self, dim, lb, ub, n_pop=40, max_iter=300, F_min=0.4, F_max=0.9, CR_min=0.6, CR_max=0.95): self.dim = dim self.lb = np.array(lb, dtype=float) self.ub = np.array(ub, dtype=float) self.n_pop = n_pop self.max_iter = max_iter self.F_min = F_min self.F_max = F_max self.CR_min = CR_min self.CR_max = CR_max self.pop = self._lhs_init() self.fitness = None self.best_x = None self.best_f = np.inf self.history = [] # 拉丁超立方采样初始化,比纯随机覆盖更均匀 def _lhs_init(self): samples = np.zeros((self.n_pop, self.dim)) for d in range(self.dim): perm = np.random.permutation(self.n_pop) for i in range(self.n_pop): rand = np.random.rand() samples[i, d] = self.lb[d] + (perm[i] + rand) / self.n_pop * (self.ub[d] - self.lb[d]) return samples def _clip(self, x): return np.clip(x, self.lb, self.ub) def optimize(self, obj_func): self.fitness = np.array([obj_func(ind) for ind in self.pop]) best_idx = np.argmin(self.fitness) self.best_f = self.fitness[best_idx] self.best_x = self.pop[best_idx].copy() self.history.append(self.best_f) for g in range(self.max_iter): t = g / self.max_iter F = self.F_min + (self.F_max - self.F_min) * (1 - t) ** 2 CR = self.CR_min + (self.CR_max - self.CR_min) * t for i in range(self.n_pop): idxs = [j for j in range(self.n_pop) if j != i] r1, r2, r3 = np.random.choice(idxs, 3, replace=False) v = self.pop[r1] + F * (self.pop[r2] - self.pop[r3]) # 柯西扰动,以当前最优个体为中心,强度随迭代衰减 cauchy_scale = 0.05 * (1 - t) v = v + cauchy_scale * np.random.standard_cauchy(self.dim) * (self.best_x - v + 1e-9) v = self._clip(v) jrand = np.random.randint(self.dim) u = np.zeros_like(self.pop[i]) for j in range(self.dim): u[j] = v[j] if (np.random.rand() < CR or j == jrand) else self.pop[i][j] u_f = obj_func(u) if u_f < self.fitness[i]: self.pop[i] = u self.fitness[i] = u_f # 精英保留:把全局最优个体放回种群 worst_idx = np.argmax(self.fitness) self.pop[worst_idx] = self.best_x.copy() self.fitness[worst_idx] = self.best_f cur_best = np.argmin(self.fitness) if self.fitness[cur_best] < self.best_f: self.best_f = self.fitness[cur_best] self.best_x = self.pop[cur_best].copy() self.history.append(self.best_f) return self.best_x, self.best_f, self.history # 场景参数 n_panels = 12 dim = n_panels * 4 lb = [] ub = [] for _ in range(n_panels): lb += [0.8, 0.5, 10.0, -45.0] ub += [19.2, 11.5, 60.0, 45.0] # 跑改进型DE np.random.seed(42) ide = ImprovedDE(dim, lb, ub, n_pop=40, max_iter=300) best_x, best_f, history = ide.optimize(pv_objective) print("最优适应度:", best_f) print("最优布局(每行: x, y, tilt, azimuth):") print(best_x.reshape(n_panels, 4))代码里面有两个地方要额外说。种群初始化用了拉丁超立方采样而不是纯随机,原因是超立方采样能让初始种群在48维空间里覆盖得更均匀,避免随机数扎堆导致初始种群集中在某一小片区域。精英保留则保证进化过程中已经找到的最优解不会被随机生成的新个体覆盖掉,这在约束惩罚场景里尤其重要,因为一个新个体可能辐射更好但越界严重,适应度不如当前最优,但贪心替换逻辑不会自动保护最优解。
4.3 标准DE与改进DE的实测对比解读
我在同样的场景参数下跑了标准DE和改进DE,各跑200代,种群规模都设为40,随机种子一致。
从收敛曲线看,标准DE在20代左右快速下降,然后基本停滞,200代结束时最优适应度大约在-6.2左右。改进DE在前30代的表现和标准DE差不多,但30代之后仍然保持缓慢下降,柯西扰动让种群多次跳出局部极值,200代结束时最优适应度能到-7.8附近。数值上的差异看起来不大,但对应到工程上,代表总辐射项更高、阴影惩罚更少,折合成年发电量会有可感知的提升。
最优布局的解读也很有意思。改进DE给出的方案里,12块板基本分散在整个20×12米的区域内,任意两块板中心距都保持在3米以上,不会出现扎堆现象。倾角集中在25到35度之间,接近我们设定的最佳倾角30度。方位角大多落在-20度到20度之间,整体偏向正南。这些特征完全符合光伏设计的直觉,说明算法搜索出的方案是有工程意义的。
有一点必须提醒:DE算法本身带有随机性,每次运行结果不会完全一样。我这篇文章里给出的数值只是单次运行的结果,你需要多跑几次、看适应度的均值和波动范围来评估算法效果。实际项目里我一般会不同随机种子跑10次,取最优方案作为最终输出。
5. 调参、踩坑与后续扩展
5.1 我在这个项目里踩过的三个坑
第一个坑是惩罚系数设置不当。最开始我把边界惩罚系数设成2,结果算法输出了一大堆越界的“优秀方案”,板都排到屋顶外面去了。原因很简单:辐射项的值域在0.8到1之间,12块板总量在9.6到12之间,越界一点点的惩罚只有2,算法觉得越界的代价远低于发电收益。后面我把边界惩罚调到10,阴影惩罚调到0.8,才把解摁回可用区域。这个教训是:惩罚系数必须比目标函数的正常变化范围高一个数量级,否则约束形同虚设。
第二个坑是柯西扰动幅度一开始固定不衰减。测试时我发现迭代后期最优适应度会在小范围内反复震荡,怎么都压不下来。原因就是后期本应该精细搜索,但每次柯西扰动的幅度还是那么大,好不容易快收敛了又给踢出去。改成随迭代衰减的scale之后,震荡立刻消失了。这个改动的角度来看很简单,但实际发现它花了半天调试时间。
第三个坑是整数变量处理。最初版本里我把面板的x、y坐标直接离散成网格索引,想用网格约束来降低维度。结果算法的搜索空间变成阶梯状,差分向量经常在两个整数之间失效,种群多样性快速归零。后来改成连续优化,最后再取整,问题才解决。做这类优化一定要记住:让算法在连续空间里跑,离散处理放到最后输出阶段。
5.2 参数速查表与可迁移的扩展方向
以下是我在光伏布局场景下实践下来比较稳的参数区间:
| 参数 | 含义 | 推荐范围 | 备注 |
|---|---|---|---|
| NP | 种群规模 | 40到80 | 决策变量维度高时取上限 |
| F_max | 变异因子上限 | 0.9 | 前期探索用 |
| F_min | 变异因子下限 | 0.4 | 后期精细搜索用 |
| CR_min | 交叉概率下限 | 0.6 | 前期保护有效结构 |
| CR_max | 交叉概率上限 | 0.95 | 后期加速收敛 |
| cauchy_scale_max | 柯西扰动最大幅度 | 0.05到0.1 | 根据目标函数尺度调整 |
| max_iter | 最大迭代次数 | 200到1000 | 视维度与惩罚复杂度增大 |
这套代码的可迁移性很强。如果你手里的项目不是屋顶光伏,而是停车棚光伏、地面电站组件排布,或者干脆是设备选址问题,只需要改目标函数里的辐射模型、约束条件和边界范围即可。如果目标不止发电量最大,还要同时考虑投资成本、运维便利性,那就把单目标改成多目标,用带权重的线性组合或NSGA-II那套非支配排序思路扩展,微分进化的变异交叉框架完全可以继续复用。
我个人的习惯是用这个算法输出10个备选方案,然后放入专业辐照仿真软件里做精细复算。算法负责快速缩小搜索范围,专业软件负责精确评估,两者结合才能兼顾效率和准确度。这个方法我用了很多次,每个项目省下的时间基本都以天为单位计算。
最后分享一个小技巧。跑优化之前,先用极少的迭代次数、较大的种群规模跑一遍,把当前方案空间里的适应度分布大概摸清楚,观察惩罚项和辐射项的量级关系。这一步只花一两分钟,却能把后面调参的时间省下一大半。优化算法从来不是“设完参数就不管了”,前期的快速试错和后期的人工判断才是真正拉开效率差距的地方。