电网调度和频率控制,在我刚入行那几年一直被当成两个“战壕”里的工作:做经济调度的人天天盯机组负荷率、煤耗曲线、启停顺序,追求的是每一度电发得够便宜;做频率控制的人则盯着AGC、一次调频死区、系统惯量,追求的是电压和频率别越线。两条线的工具、模型、时间尺度完全不是一个量级,以前开会都各讲各的。直到后来做新能源高比例接入的项目,我才意识到这两个层必须放在同一个框架里建模——调度是小时级的经济决策,频率响应是秒级到毫秒级的物理动态,中间还夹着分钟级的滚动调度。要把这三个层次同时塞进一个可复现的模型框架里,还要把代码从论文变成能跑的工程实现,我选择用Julia从零开始做复现。这篇文章就是完整的过程记录,包括模型拆分、关键代码、性能调优,以及我踩进去又爬出来的那些坑。
1. 电网经济与频率控制为什么必须放在一个框架里
1.1 经济调度与频率控制之间的“尺子”不一样
传统电力系统运行里,经济调度解决的是“哪些机组出力、出多少力”的问题,优化周期通常是15分钟到24小时,物理上对应的是机组爬坡、启停、煤耗这些慢过程。频率控制则解决的是“负荷突变后转速怎么稳住”的问题,时间常数是秒级,甚至毫秒级,背后是转子惯量、调速器响应、AGC闭环这些快过程。
两者的动态特性差了大约四个数量级。要是用同一个时间步长去仿真,那调度问题没法算——秒级步长跑24小时,迭代次数太多;要是忽略快过程,那调度结果又失真——频率掉得厉害的时候,调度给的出力基点早就失效了。所以必须做分层建模,每一层用自己合适的时间尺度去建模和求解,然后再把层与层之间的边界条件衔接起来,这就构成了所谓的多层多时间尺度模型。
1.2 多层多时间尺度模型到底在解决什么问题
新能源渗透率上来以后,原来的“先调度、再调频”串行思路就不够用了。光伏和风电的出力波动大,既影响经济性也影响频率质量。过度追求经济最优可能导致系统惯量变低,一次调频能力不足;反过来,过度保守的调度会压着机组出力,让经济性变差。要同时考虑经济性和频率安全,就需要一个能够体现它们之间约束关系的模型。
多层多时间尺度模型的本质,是把这两个问题拆成层次结构,而不是简单拼接。上层做经济规划和调度,给出各机组的基准出力;下层做频率闭环控制,根据实时频率偏差修正出力。上下层之间通过机组出力的设定值、可调容量、调节速率这些物理量互相约束。数据是双向流动的:上层给下层提供运行基点,下层给上层反馈调节余量。这样才算真正把“经济决策”和“动态控制”串成了一个整体。
2. 模型架构:三层三尺度怎么划分
2.1 顶层经济调度:小时级的机组出力分配
顶层模型的输入是负荷预测、新能源出力预测和各机组参数,输出是未来24小时甚至更长时间段内各机组的出力计划。目标函数是让系统总发电成本最小,包括燃料成本、启停成本和必要的备用成本。约束条件除了功率平衡、机组上下限、爬坡速率、最小启停时间之外,还必须加入频率安全相关的约束,最典型的做法是给系统设定一个最低惯量水平或一次调频备用容量约束。
在Julia里,这个模型我直接用JuMP.jl搭,求解器用HiGHS。HiGHS是开源求解器里对线性规划和凸二次规划支持很稳的,不需要商业授权,复现的时候也没有版权负担。模型架构上先按机组类型分组,再按时段展开,最终形成一个带机组组合变量的混合整数规划问题。对于纯经济调度不考虑机组启停的简化场景,只需要把机组组合变量去掉,退化成纯粹的连续优化问题,求解速度和稳定性都会大幅度提升。
2.2 中层滚动调度:分钟级的负荷跟踪
中层解决的问题是:顶层给出的小时级出力计划太粗糙,负荷在15分钟甚至5分钟尺度上有明显波动,如果不跟踪,频率控制层会承受过大的调节压力。所以中层模型采用滚动时域控制的思想,每15分钟刷新一次,使用最新的负荷预测和新能源预测,将未来4小时内的机组出力基点重新优化一遍。
这一层的模型和顶层结构类似,但多了两个关键变化:一是时间步长更细,成本函数要考虑更精细的爬坡约束;二是需要把上一轮优化结果中已确定的部分作为初始状态,不能随意跳变。实际代码里,这里我会用一个滚动循环,每次循环都重新构建并求解一次JuMP模型,然后只取第一个或前几个时段的解,作为下一层频率控制器输入的基点。这其实就是标准MPC流程,难点不在模型本身,而在求解速度和数值稳定性。
2.3 底层频率控制:秒级到毫秒级的动态响应
底层模型面向的是系统频率的动态过程。这里我采用集中惯量模型,也就是把所有发电机组的转子运动方程合并成一个等效方程,再用一个一阶惯性环节来近似调速器和汽轮机的响应延迟。系统频率偏差的微分方程可以写成:
2H_total * dΔf/dt = ΔP_gen - ΔP_load - D * Δf其中H_total是系统等效惯量,D是负荷频率调节系数,ΔP_gen是发电机机械功率增加量,ΔP_load是负荷扰动。一次调频由调速器自动完成,二次调频由AGC参考频率偏差和区域控制误差去调整功率设定值。这里我还给调速器加了一个限幅,避免出力调节超过机组爬坡能力。
底层仿真用DifferentialEquations.jl来做,时间范围取30到60秒,足以观察一次调频的暂态过程和二次调频的恢复趋势。这一层的输出是频率偏差曲线、机组调节功率曲线,通过这些曲线反过来定量评估顶层调度方案是否满足频率安全要求。
3. Julia源代码复现:从模型到可运行代码
3.1 为什么我选Julia而不是Python
说得直接一点,这个任务放在Python里也能做,但非常别扭。调度建模用PuLP或者Pyomo倒是没问题,可一旦要做秒级动态仿真,加上滚动优化循环,Python的解释器开销和内存分配问题就让人头疼。我试过用Python实现一个简化版,同一个案例仿真跑下来要十几秒,而Julia版本冷启动之后基本能压到一秒以内。
Julia的优势在于它既是动态语言又是编译型语言。写起来脚本感很强,变量不用声明类型,理解成本低;但运行时通过LLVM编译成机器码,数值计算性能接近C和Fortran。再加上JuMP建模语法和Python的PuLP非常接近,迁移成本很低。对于这种既要搭优化模型、又要跑动态仿真、还得分层循环计算的任务,Julia是当下最顺手的选择。
3.2 项目结构、依赖与求解器选型
代码我按模块分文件组织,结构清晰一些,后面换机组数据、改参数也方便。项目结构大概是这样:
pkg/ Project.toml src/ model/ Economy.jl Frequency.jl Rolling.jl data/ units_5gen.csv run_pipeline.jl utils.jl核心依赖只有四个:JuMP.jl负责优化建模,HiGHS.jl作为开源求解器,DifferentialEquations.jl负责频率动态仿真,CSV.jl和DataFrames.jl负责读取机组参数。性能分析的时候再加BenchmarkTools.jl,不需要一上来就引入一堆重型依赖。
求解器选型上,HiGHS适合中等规模线性规划和二次凸规划。如果你要跑几千台机组的机组组合,可以换Gurobi或者CPLEX,但复现阶段HiGHS足够。频率动态仿真选Tsit5方法,这属于自适应步长的龙格库塔法,精度和速度的平衡比较好,不需要手动调步长。
3.3 经济调度层的核心实现
经济调度的核心代码不算长,我直接贴最核心的部分:
using JuMP, HiGHS function build_dispatch(gen, load_curve; T=24) n = length(gen) model = Model(HiGHS.Optimizer) set_silent(model) @variable(model, P[1:n, 1:T] >= 0.0) for t in 1:T @constraint(model, sum(P[i, t] for i in 1:n) == load_curve[t]) end for i in 1:n, t in 1:T @constraint(model, P[i, t] <= gen[i].Pmax) @constraint(model, P[i, t] >= gen[i].Pmin) if t > 1 @constraint(model, P[i, t] - P[i, t-1] <= gen[i].ramp_up) @constraint(model, P[i, t-1] - P[i, t] <= gen[i].ramp_dn) end end @objective(model, Min, sum(gen[i].c2 * P[i, t]^2 + gen[i].c1 * P[i, t] + gen[i].c0 for i in 1:n, t in 1:T) ) optimize!(model) @assert termination_status(model) == OPTIMAL return value.(P), objective_value(model) end这里有几个关键细节。第一,成本函数我用的是二次函数,但HiGHS默认处理的是线性规划,二次目标函数它会自动调用对应的QP算法,实测没有问题。第二,爬坡约束是相邻时段之间的出力差限制,这个约束对中层滚动模型尤其重要,否则调度结果会让机组频繁大幅度变出力。第三,我用set_silent(model)关掉了求解器日志,不然跑滚动优化的时候控制台会刷爆炸。
3.4 频率动态层的核心实现
频率动态层我不需要复杂的系统模型,重点是把转子运动方程和调速器响应组在一起:
using DifferentialEquations function freq_dynamics!(du, u, p, t) df = u[1] # 频率偏差 pg = u[2] # 调速器机械功率增量 # 一次调频:按频率偏差比例调整功率,带限幅 pg_ref = -p.R * df du[2] = (clamp(pg_ref, -p.limit, p.limit) - pg) / p.tau_g # 二次调频:AGC按区域控制误差积分 pagc = p.Ki * df # 功率平衡方程 du[1] = (pg + pagc - p.disturbance - p.D * df) / (2.0 * p.H) end function run_frequency(p) u0 = [0.0, 0.0] tspan = (0.0, 60.0) prob = ODEProblem(freq_dynamics!, u0, tspan, p) sol = solve(prob, Tsit5(); reltol=1e-6, abstol=1e-8) return sol end参数p是一个结构体,用Base.@kwdef定义,字段包括惯量H、调速器下垂系数R、调速器时间常数tau_g、AGC积分系数Ki、负荷扰动disturbance等。这个结构体还有一个好处:Julia对参数类型的推断会非常精确,仿真性能比用Dict存参数快得多。
这一层最容易出问题的地方在clamp函数。调速器出力是有物理上限的,如果不加限幅,大扰动下仿真会出现频率又飞、AGC又狂拉功率的失真现象。我在实际复现时发现,加限幅之后频率最低点会偏低一些,但这个结果才是符合物理实际的。
3.5 多时间尺度耦合的关键代码
三层模型不应该是三个孤立脚本,真正的联动在耦合逻辑里。我的实现思路是:先跑顶层调度得到24小时的基准出力,然后按15分钟粒度插值得到中期基点;中层滚动模型根据最新的负荷修正基点;最后把修正后的基点作为频率仿真里的初始机械功率初值,跑一段大扰动仿真看频率曲线。
function run_pipeline(data, load_day) # step 1: 日前经济调度 P_day, cost = build_dispatch(data.gen, load_day; T=24) # step 2: 日内滚动修正,15分钟步长,窗口4小时 P_roll = rolling_dispatch(data, P_day, load_day) # step 3: 频率仿真,在第10秒施加0.05 p.u.负荷扰动 p = data.params p.disturbance = 0.05 p.Pset0 = P_roll[1] # 取滚动结果第一个时段作为初始基点 sol = run_frequency(p) # step 4: 评估频率安全指标 nadir = minimum(sol[1, :]) return (dispatch=P_day, rolling=P_roll, freq=sol, nadir=nadir) end三层之间的数据流就这样串起来了。实际运行中我还检查了一个关系:顶层调度给的备用容量如果不够,频率仿真里纳底就会很低,甚至低于49.5Hz的安全线。这就是经济调度和频率控制耦合的直观体现——只看经济性不检查频率安全,方案可能非常危险。
4. 运行效果与性能调优实录
4.1 三个时间尺度跑通后的实测结果
我用的测试系统是一个5机组模型,包含两台火电、一台燃气轮机、一个风电场和一个储能电站。负荷曲线取典型夏季峰荷日,最大负荷650MW,风电处理成负的净负荷参与平衡。
运行完整pipeline之后,日前调度给出的小时级出力曲线比较平滑,但15分钟滚动调度会明显跟踪到负荷的小幅波动。对频率层施加一个0.05 p.u.的负荷扰动后,系统频率在扰动后3秒左右到达最低点,经过一次调频粗调然后AGC缓缓拉回接近50Hz。整个仿真60秒,频率最低点、稳态偏差这两个安全指标都能直接读出来。
这个流程的价值在于:顶层调度方案改了哪怕一个参数,比如把某台机组的Pmax调低,频率层的纳底立刻会变。这就验证了多层模型之间确实存在强耦合关系,单层模型根本发现不了这种连锁影响。
4.2 Julia性能优化的几个关键动作
第一版代码非常慢,慢在哪?慢在JuMP模型的反复构建。中层滚动优化每15分钟构建一次模型,内层还有变量索引的各种操作,一旦模型规模上来,构建时间会远超求解时间。我把模型构建封装成了独立函数,并确保每次循环时复用固定的变量索引结构,构建时间显著下降。
第二个关键动作是函数壁垒。Julia的JIT编译是按函数粒度触发的,我在所有核心计算外面套了一层长函数,比如把整个滚动优化循环包进rolling_dispatch函数里,避免在全局作用域写循环。全局变量会让类型推断失效,每次循环都触发编译或动态派发,性能会掉一个数量级。
第三是循环里避免分配。我检查了滚动调度里的临时数组,把不必要的重建改成了预分配缓冲,用@views操作切片。Julia里数组切片默认是复制,多复制几轮之后GC开销非常大,这点和Python的习惯完全不同。
4.3 内存管理:不求极致但求稳定
Julia的内存管理核心是GC机制,但GC不会自动帮你避免分配。实测中发现一个现象:跑完整pipeline时,如果没有手动控制中间变量生命周期,内存会一点一点涨上去。原因是JuMP模型的内部对象在滚动循环里不断创建,前一轮模型虽然不再引用,但GC触发时机不可控。
我的做法是在滚动循环末尾显式置空引用,然后定期调用GC.gc()。这不是最优雅的方案,但在复现阶段非常有效。另一个经验是把不依赖数据的模型参数提升为常量,例如机组数量、时段数量,用const声明,这会帮助编译器进行常量传播。
如果要做超大规模仿真,可以用--heap-size-hint参数控制堆大小,或者考虑用PackageCompiler预编译成系统镜像,可以把启动时间从几秒压到零点几秒。对于一次跑几十个算例的分析需求,这个优化非常值得做。
5. 复现过程中踩过的坑
5.1 经济调度模型无解:多半不是求解器的问题
我遇到的第一类坑是模型无解。最初我还以为是HiGHS对某些极端约束处理不好,查了半天发现完全是自己建模的问题。典型错误是:机组总容量刚好等于负荷,但没有留出备用容量;或者爬坡约束太紧,导致相邻时段无法满足负荷上升速度。解决办法很朴素,先把机组总容量加大10%到15%,或者把爬坡速率放宽,再逐步收紧看影响。建模时先跑通再调参,别一上来就追求高精度。
另外要注意约束的物理单位一致性。负荷曲线我习惯用MW,而建立爬坡约束时用的是“每个时段内的变化量”,如果不除以时间步长,就等于假设爬坡能力是1小时内完成的,实际却是15分钟尺度,数值上会差出好几倍。
5.2 频率控制仿真不收敛或发散
频率动态仿真初期遇到过发散。找了半天原因是AGC积分系数Ki给得太大,频率偏差小的时候反馈很轻微,一旦扰动稍大,积分项快速积累,功率设定值就会超出物理极限,仿真自然就爆了。解决办法不只是调Ki数值,还要在控制器输出侧再加一个限幅器,这个经验说白了就是控制工程里的抗饱和。
微分方程求解器报错其实也是一种提示。我在把扰动从0.02改成0.10时,Tsit5的默认容差开始出现明显振荡。后来把reltol和abstol分别压到1e-6和1e-8,60秒的仿真时间只增加了一点点,但曲线光滑多了。频率动态问题对精度不敏感,但容差太低会产生锯齿,影响对纳底的判断。
5.3 Julia初印象陷阱:纯Julia风格版本
从Python转过来的第一版代码处处都有Python的影子:大量Dict传参、大量中间数组、到处是global变量。运行起来并不出彩,比Python还慢。后来我理解Julia是一门“可以写得像Python但本质是编译型”的语言,于是开始改成结构体传参、函数屏障、类型稳定。改完后的版本和初版对比,速度提升了差不多十倍,内存分配减少到原来的三分之一。
Julia里还有一个让人迷惑的地方是首调编译时间。第一次跑run_pipeline需要等十几秒甚至二十秒,中间还会出现编译日志。这很正常,不算性能问题。我自己会在批量跑算例之前先跑一个小规模预热,把相关方法都编译一遍,后面的重复调用就会很顺畅。
6. 后续扩展与个人体会
6.1 可以往哪些方向扩展
这套框架的扩展空间非常大。最直接的方向是把顶层经济调度升级成完整的机组组合模型,加入启停变量和最小启停时间约束,模型变成混合整数规划。频率控制层也可以从集中惯量模型扩展成多机分布式模型,每台机组单独建转子方程,能研究机电振荡和区域间低频振荡。再者,滚动调度层还可以加入不确定性集合,做鲁棒优化或者随机优化。
如果对Julia本身更感兴趣,还可以把整个模型封装成包,定义简洁的输入输出接口,做成一个开源的复现工具。到那一步,Julia的语言优势会进一步体现——文档、测试、包管理都是工程级别的体验。
6.2 一点私货经验
回头来谈一点个人的实际体会。能源领域做研究,很多论文给出的模型都特别漂亮,但复现时最难受的往往不是算法本身,而是模型假设和代码实现之间的那条鸿沟。这篇复现做下来,我对多层多时间尺度的理解比看十篇综述都要深。频率控制层的惯性常数是怎么受机组组合影响的、调度方案为何不能忽略动态约束,这些抽象描述最终变成了我眼前的一条频率下降曲线。
我特别建议有条件的人都去亲手跑一遍这样的复现,尤其用Julia。它的上手速度比想象中快,调试体验又比传统编译型语言友好,非常适合把理论模型转化成可运行、可重复实验的工程代码。循环迭代模型的时候,Julia带来的快速反馈会让你有一种在实验室里调设备的实感。这个体验,是看任何代码片段都没法替代的。