削峰填谷(Peak Shaving / Valley Filling)
峰谷套利 · 按时间段主动充放电 · 抬升谷期负荷、压低峰期负荷
一句话:让电池在电价贵的时段多放一点、在电价便宜的时段多充一点,顺便不让变压器在峰期过载。
这个功能解决什么问题
家庭或工商业储能的经济性主要来自峰谷价差。但「什么时候算峰、什么时候算谷」是用户配的,
电压等级和用电曲线又各不相同,所以固件不能把「峰谷」写死成 8:00–20:00,
而是把一天切成 最多 2 段削峰窗口 + 2 段填谷窗口,全部由配置表驱动。
峰期的目标不是「放到底」,而是「把从电网取的功率压到一个上限以内」——这个上限就是核心参数
u16_peak_value。
数据怎么流进来 → 在哪算 → 怎么发出去
-
流进来
用户的时段与阈值从云端下发,落进 Flash 的调度配置表
g_t_logic_info.t_config_data(LOGIC_CONFIG_TAB_T,定义在logic/logic.h)。 每段窗口记成「分钟数」,字段名形如PeakStartTime1_minutes/PeakEndTime1_minutes/ValleyStartTime1_minutes/ValleyEndTime1_minutes(各两段)。 -
判断
task_system_logic_control每轮调用logic_control_by_time()logic/logic.c:1450, 在里面用logic_comparing_time_periods()logic/logic.c:1386 把「当前星期 + 当前分钟数」逐段比对。 只有使能位u8_peak_shaving_en为真时才做这段判断 logic/logic.c:1507。 -
落中间量
比对结果写成两个总表里的标志:
u8_time_peak_clip_flag(现在处于削峰窗口)与u8_time_valley_fill_flag(现在处于填谷窗口); 同时把阈值搬进来:s32_set_peak_value = u16_peak_value, 以及放电截止 SOCu8_peakshving_discharge_cut_soclogic/logic.c:1515-1546。 -
选模式
模式优先级链命中
case TIME_PEAK_SHAVING_MODE:,调用time_peakshaving_command()logic/logic.c:2326 → 550。 这个函数只在两个标志上做分发,并用条件编译区分 AC 控 / DC 控机型。 -
算目标功率
交流耦合(AC 控):
ac_ctrl_time_peak_clip_command()logic/ac_control.c:1674 →time_peak_clip_calculate()logic/ac_control.c:728。 填谷侧:ac_ctrl_time_valley_fill_command()logic/ac_control.c:1723 →time_valley_fill_calculate()logic/ac_control.c:749。
直流耦合(DC 控):dc_ctrl_time_peak_clip_command()logic/dc_control.c:1987 →dc_time_peak_clip_calculate()logic/dc_control.c:1963;dc_ctrl_time_valley_fill_command()logic/dc_control.c:1951 →dc_time_valley_fill_calculate()logic/dc_control.c:1869。 -
限幅下发
time_peakshaving_command()末尾统一调用logic_charge_dischg_power_limit(),把目标值钳进电池充放能力与安规限值, 最终落到s32_ac_total_set_inv_power/s32_dc_total_set_bat_power, 由midware/inverter/按厂商协议组帧发给逆变器。
关键函数与行号
| 作用 | 函数 / 符号 | 位置 |
|---|---|---|
| 模式分派(进入削峰填谷模式) | case TIME_PEAK_SHAVING_MODE | logic/logic.c:2325 |
| 削峰填谷命令总入口(只在两个标志上分发) | time_peakshaving_command() | logic/logic.c:550 |
| 时段总判断 + 中间量落地 | logic_control_by_time() | logic/logic.c:1450 |
| 「当前星期 + 分钟数」是否落在某段窗口内(支持跨天) | logic_comparing_time_periods() | logic/logic.c:1386 |
| 削峰填谷使能判据(总开关) | u8_peak_shaving_en | logic/logic.c:1507 |
| AC 侧削峰状态机 + 计算 | ac_ctrl_time_peak_clip_command()time_peak_clip_calculate() | logic/ac_control.c:1674 logic/ac_control.c:728 |
| AC 侧填谷 | ac_ctrl_time_valley_fill_command()time_valley_fill_calculate() | logic/ac_control.c:1723 logic/ac_control.c:749 |
| DC 侧削峰 / 填谷 | dc_ctrl_time_peak_clip_command() / dc_time_peak_clip_calculate()dc_ctrl_time_valley_fill_command() / dc_time_valley_fill_calculate() | logic/dc_control.c:1987 / :1963 logic/dc_control.c:1951 / :1869 |
| 读放电截止 SOC | logic_read_peakshving_discharge_cut_soc() | logic/logic.c:3077 |
| 充放电功率限值总入口 | logic_charge_dischg_power_limit() | logic/logic.c(由 time_peakshaving_command() 末尾调用) |
它到底是怎么算的(AC 侧削峰)
time_peak_clip_calculate() logic/ac_control.c:728 只有两个分支加一个空分支,
核心是一句话:用「实测取电功率」减去「目标上限」,差多少就补多少放电。
-
超上限了:
s32_pgrid_metert ≥ s32_set_peak_value时,s32_ac_total_set_inv_power = s32_pinvt + |s32_pgrid_metert − s32_set_peak_value|—— 在「当前逆变功率」的基础上补上超出量,把取电压回上限。 -
明显低于上限了:
s32_pgrid_metert ≤ s32_set_peak_value − s32_set_delta_value时, 反向减掉一个量,且这个量要再除以u8_regulator_speed(调节速率档位) 做渐进回收,避免功率阶跃。 - 中间那段什么都不做:落在「上限 − 回差」与「上限」之间时保持原样。 这就是回差(死区),防止系统在阈值附近来回抖。
外层还有一层状态机抖动抑制:s_u8_peak_status / s_u8_peak_old_status
(ac_control.c:1676-1677)。低于上限时先切到 NO_INVERTER_POWER_STATE 置 0;
但若上一轮还在调节态(POWER_REGULATION_STATE),就保持调节态继续算,
算完再判断「补完之后是否已经低于上限」,是则当轮又切回置 0。这样阈值附近不会出现
「调一下停一下」的振荡。DC 侧 dc_ctrl_time_peak_clip_command() 逻辑完全对称,
只是目标量换成 s32_dc_total_set_bat_power,并且放电量额外乘了 1.1 的裕度系数
logic/dc_control.c:1968。
填谷为什么先「改写电表功率」
time_valley_fill_calculate() logic/ac_control.c:749 的第一步不是直接给电池下充电功率,
而是就地改写逻辑表里的电表读数:
s32_pgrid_metera/b/c 各减去
s32_set_valley_value / u8_inv_phase_num(三相平分),总量也同步减,
并把 s32_max_feedin_power 置 0
logic/ac_control.c:757-761。
改完再去跑自发自用的状态机 fsm_total_power_ac_control_state_handle() /
fsm_total_power_ac_control();如果状态落到了
PV_TRACK_FEEDIN_GRID_STATE,就顺手把
u8_ac_total_set_bat_forced_charge_en 置真,强制电池进入充电
logic/ac_control.c:765-768。
这个「改写电表」的设计意图
填谷的本质是「让系统以为现在负荷更大,于是多充点电」。 复用已有的自发自用状态机、只修改它的输入,比再写一套充电算法要省得多—— 这是这套固件里很典型的一种「以数据欺骗策略算法」的复用手法。 代价是可读性变差:读代码时如果不知道这里改写过, 就会以为自发自用状态机拿到的是真实电表值。
一个具体的现场场景
场景:用户反馈「设定 18:00–21:00 削峰,但 18:10 电池不放电」
-
先确认时段真的被判进来了。时段字段是「分钟数」,例如 18:00 记作 1080。
如果云端下发时把 18:00 写成了 1800(当成「18 点 00 分」直译),
就落到了 30:00,永远不命中。先看
PeakStartTime1_minutes的实际取值。 -
再确认总开关与模式。削峰填谷要求
u8_peak_shaving_en为真 logic/logic.c:1507; 并且序控模式链必须落到TIME_PEAK_SHAVING_MODE。 如果此刻有云端调度指令或强制充放电在生效,它们的优先级更高, 削峰填谷这一段根本不会执行——现象就是「时段对了但没动作」。 -
最后看是不是「已经低于上限」。削峰的目标是压到
s32_set_peak_value以下。 如果 18:10 全家还没回家,取电功率本来就低于上限, 那么NO_INVERTER_POWER_STATE会把设定功率置 0, 电池不放电是正确行为。这时用户看到的「不放电」其实说明策略在正常工作。
判断方法:看 s32_pgrid_metert(电网表总有功)与 s32_set_peak_value 的关系。
前者低于后者 → 无需削峰。
容易踩的坑
坑 1 · 把「削峰」理解成「一定放电」
削峰是闭环压上限,不是「定时放电」。取电低于上限时设定功率被主动置 0 logic/ac_control.c:1700-1701。现场考核「有没有放电」是错的问题, 应该问「取电有没有被压到上限以下」。
坑 2 · 填谷就地改写电表值,排查时会看错数据
time_valley_fill_calculate() 会扣减 s32_pgrid_metera/b/c 与总量
logic/ac_control.c:757-761。
在这个函数之后的同一轮里,逻辑表里的电表功率已经不是实测值。
如果在这之后打印调试信息并与电表实际读数对照,会对不上,从而误判为「电表读数错误」。
坑 3 · AC 控 / DC 控两套实现,行号不同但名字极像
两套函数只差前缀与一两个字母:time_peak_clip_calculate()(AC)对
dc_time_peak_clip_calculate()(DC);
ac_ctrl_time_valley_fill_command() 对
dc_ctrl_time_valley_fill_command()。
更麻烦的是 time_peakshaving_command() 内部用
#ifdef INV_AC_CTRL / #ifdef INV_DC_CTRL 分发
logic/logic.c:554-568。
在明文基线里这两个宏是否被定义,取决于工程宏配置(见 §5.1),
所以「改了没生效」的第一嫌疑就是改错了那一侧。
坑 4 · 两个窗口重叠时会被合并
两段削峰窗口的判据是「或」关系:
l_u8_time_peak_flag |= (l_u8_temp_flag == 0)
logic/logic.c:1517。
所以两段窗口即使重叠也只表现为「在削峰」,不会叠加放电量;
同样,如果用户把填谷窗口配进了削峰窗口里,两个标志会同时为真,
而 time_peakshaving_command() 用 if / else if
让削峰优先 logic/logic.c:552-570——填谷会被静默跳过。