电力需求响应信息模型:集中式空调可调容量建模与核验落地
简介:《电力需求响应信息模型 第1部分:集中式空调系统》是中国电力企业联合会发布的 T/CEC 239.1—2019 标准原文,面向智能电网需求响应终端研发人员、空调设备厂商、系统集成商与电力科研学习者,用于解决集中式空调负荷参与电网调峰时,数据格式与接口不统一的问题。压缩包内仅有 1 个 PDF 文件,约 396KB,完整收录标准正文和资料性附录:正文依次给出建模原则、数据类型、通用包与专用包的定义,涵盖整数、字符串、浮点、布尔、日期时间等基础类型及设备运行状态、功率等属性描述;附录 A 说明需求响应能力的计算方法。标准适用于自动需求响应终端与接口的设计开发,对可扩展性、互操作性提出明确要求,也为分散式空调、电热水器等同类负荷的信息模型提供参照框架。目前已有 307 人学习,适合需要对照条文完成接口建模、协议实现或节能策略设计的读者查阅。
1. 从一次「空调降了 200 kW、结算只认 80 kW」说起
某园区把冷冻站接进需求响应平台,指令下发后总功率确实掉了两百多千瓦,设备没坏、通信没断,可到了结算环节,调度侧只认八十千瓦。问题出在双方对「降了多少、降了多久、什么时候能恢复、计量边界在哪」的描述不一致:平台按铭牌估,电网按表计核,中间那 120 kW 就凭空蒸发了。电力需求响应信息模型第 1 部分锁定集中式空调系统,干的就是把这类负荷该上报什么字段、可调能力怎么表达、事件如何交互、核验依据是什么,用一套结构固定下来。手里有一堆冷水机组、却不知道怎么把它们翻译成调度能读懂的语言的人,从这一层开始啃最省事。
2. 把冷冻站拆成可上报的对象:四层结构、三类属性和一份物模型
2.1 系统—机组—回路—末端:只报一个总功率为什么一定出问题
刚接触这活儿的人最容易犯的错,是把整个冷冻站当成一个黑匣子,只上报一个总功率和一个「可降容量」。这么做在演示阶段很顺,一进真实事件立刻露馅:调度问「你打算动哪台设备」,答不上来;运维问「是哪台机组没执行」,查不出来;计量问「这个数从哪块表来的」,对不上号。
可靠的做法是按使用方拆成四层,每层服务一个明确的角色。
| 层级 | 典型对象 | 服务对象 | 关键属性 | 建议上报周期 |
|---|---|---|---|---|
| 系统层 | 冷冻站整体 | 调度、合同结算 | 总有功、总可调容量、运行模式、参与状态 | 60 s |
| 机组层 | 冷水机组、冷冻水泵、冷却水泵、冷却塔风机 | 指令执行、运维定位 | 额定功率、负载率、启停状态、故障码 | 5~15 s |
| 回路层 | 冷冻水供回水、冷却水供回水 | 计量核验、约束判断 | 供回水温、流量、压差 | 10 s |
| 末端层 | 组合式空调箱、风机盘管 | 舒适度约束、投诉溯源 | 室温、设定值、风量档位 | 60~300 s |
系统层是聚合视图,它的可调容量不是自己算出来的,是从机组层往上汇总、再被回路层和末端层的约束削掉一块之后的结果。机组层是可执行的最小单元,一条控制指令最终必须能落到某个具体设备的某个点位上,否则这条指令就是空的。回路层承担计量和物理约束两件事:冷冻水供水温度不能无限往上抬,回水温度决定主机还能不能继续降载。末端层看起来离调度最远,但它是所有约束的来源——室温越界,前面三层的努力全部作废。
四层之间靠对象标识串起来,而不是靠字段嵌套。系统对象里存的是机组 ID 列表,机组对象里存的是所属回路 ID,这样任何一层变更都不需要动其他层的结构。我见过把整棵树塞进一个 JSON 的做法,加一台机组要改三处配置,运维直接放弃维护。
2.2 静态铭牌、实时运行、可调能力:三类属性的刷新频率差一个数量级
同一台冷水机组身上的属性,按变化速度可以切成三堆,混在一起上报是另一个高频翻车点。
静态铭牌属性变化极慢,只在设备改造、参数调整时更新:额定功率、额定冷量、额定 COP、最小负载率、是否变频、控制方式(开关型还是连续调节型)、所属回路。这类数据天级甚至周级同步一次就够,但必须准——可调容量的上限就是从这堆数里长出来的。
实时运行属性秒级变化:当前有功功率、负载率、启停状态、故障码、进出水温、流量。这是事件执行期间判断「到底降没降」的直接依据,采样周期压到 5~15 s 比较稳妥,再快对结算没有额外收益,只会把网关压垮。
可调能力属性最容易被误解成静态配置。它其实是跟随工况走的动态量:当前能降多少、多快能降到位、能撑多久、撤了之后多久能恢复。冬天和盛夏同一台机组能报出的可调容量可能差一倍,原因很简单——夏天主机负载率 85%,有得降;过渡季负载率只有 40%,再降就接近最小负载率停机边界了。
提示:可调能力属性只在事件前刷新一次是不够的,建议按 5~15 min 周期滚动刷新,并在收到邀约时强制触发一次全量刷新,避免拿着几小时前的旧值去应答。
这里有个取舍:刷新越频繁,网关负担越重,但应答准确度越高。我一般按「机组层 10 min、系统层 15 min」配置,事件窗口内临时提到 1 min。
2.3 用 YAML 写一台冷水机组的物模型:字段、单位与必填项
模型定义用什么格式不重要,重要的是字段命名、单位、取值域三件事在整条链路上保持一致。下面这份是机组层的最小可用定义,注释里标了每个字段的来源和约束。
# 冷水机组物模型定义(机组层,最小可用集) object_id: CH-01 # 全局唯一,建议 站点-类型-编号 object_type: chiller # 枚举:chiller / chw_pump / cw_pump / cooling_tower parent_id: STATION-A # 指向系统层对象 loop_id: CHW-LOOP-1 # 所属冷冻水回路 # --- 静态铭牌属性:变更时上报 --- nameplate: rated_power_kw: 350.0 # 额定输入功率,单位 kW rated_cooling_kw: 1750.0 # 额定冷量,单位 kW min_load_ratio: 0.30 # 最小负载率,低于此值将停机保护 variable_speed: true # 是否变频 control_mode: continuous # continuous 连续调节 / step 分档 / onoff 开关 # --- 实时运行属性:5~15 s --- runtime: active_power_kw: 268.4 # 当前有功功率 load_ratio: 0.77 # 当前负载率 = 实际冷量 / 额定冷量 run_state: running # running / stopped / fault / standby fault_code: "0" # 厂商故障码,0 表示无故障 chw_supply_temp_c: 7.2 # 冷冻水供水温度 chw_return_temp_c: 12.6 # 冷冻水回水温度 # --- 可调能力属性:5~15 min 或事件触发时刷新 --- flexibility: capacity_down_kw: 95.0 # 当前可下调容量 capacity_up_kw: 0.0 # 当前可上调容量,制冷工况通常为 0 ramp_kw_per_min: 18.0 # 爬坡率,每分钟可变化功率 sustain_min: 90 # 在当前工况下可持续调节时长 recovery_min: 45 # 撤出调节后恢复至原工况所需时间 step_kw: 5.0 # 最小调节步长,连续调节型填 0 # --- 元数据 --- meta: timestamp: "2024-07-18T14:32:00+08:00" # ISO8601,必须带时区 quality: good # good / uncertain / bad,采集质量码 source: gateway-03 # 数据来源网关标识字段设计上有几条硬规矩。单位一律写进字段名后缀,_kw、_c、_min,不要靠文档约定,对接方不会去翻文档。时间统一 ISO8601 带时区偏移,不带时区的时间戳在跨系统对接里就是灾难。枚举值用可读字符串而不是数字,running比1省掉一次翻译,也省掉一次翻译错误。必填和选填靠字段是否存在来表达,缺失即视为不支持,比填一个-1当哨兵值干净得多。
control_mode这个字段值得单独说。连续调节型机组可以按任意步长降载,分档型只能按固定档位跳,开关型只有开和停两种状态。三者在可调容量计算和指令下发逻辑上完全不同,模型里不区分,后面一定会在执行环节打架。
对象标识的命名建议带上站点和类型前缀,STATION-A-CH-01这种。纯数字 ID 在单站点环境下没问题,一旦聚合商接了几十个站点,合并报表时就会出现 ID 冲突,那时候再改要动全链路。
3. 可调容量怎么算才不被调度打回:基线、爬坡与持续时间三组参数
3.1 基线不是历史平均:三种算法各自的适用边界
核验环节比的从来不是「你现在多少功率」,而是「如果你不响应,现在本该是多少功率」,这个虚拟值就是基线。基线算偏了,后面所有努力都是白费。常见三种算法,各有各的适用面。
相似日均值法取事件前 5~10 个非事件日、非节假日、气象条件相近的同一时段负荷均值。它对日周期规律强的负荷很准,空调就是典型。问题出在过渡季:连续阴雨一周,找不到气象相近的历史日,硬套均值会系统性偏高,导致虚报可调容量。我的做法是加一条兜底规则,相似日不足 3 天就退回事件前基线法。
事件前基线法取事件开始前 1~2 小时的实测均值外推。它简单、不需要历史库,对突变工况适应快。缺点是抗干扰差,事件前刚好有一波加班或者一台机组检修,基线就被带偏了。工程上一般取 60 min 窗口,并剔除窗口内的异常点。
回归模型法用负荷对温度、湿度、时刻、工作日标志做拟合。理论上最准,实际落地最麻烦——需要足够长的历史数据训练,而且模型要定期重训,否则换季之后系数全错。除非聚合的站点数量够多、有专门的算法团队维护,否则不建议一上来就上。
| 算法 | 数据要求 | 过渡季表现 | 落地成本 | 适用场景 |
|---|---|---|---|---|
| 相似日均值 | 30 天以上历史负荷 + 气象 | 中,依赖相似日筛选 | 低 | 单站点、规律负荷 |
| 事件前基线 | 仅需事件前 1~2 h 数据 | 高,跟随当前工况 | 极低 | 临时接入、数据不全 |
| 回归模型 | 6 个月以上多变量历史 | 高,但需重训 | 高 | 多站点聚合、长期运营 |
实际项目里我通常两条腿走路:日常用事件前基线法兜底,同时后台跑相似日均值做交叉校验,两者偏差超过 15% 就告警,人工介入判断。这套组合不优雅,但足够稳。
还有一条常被忽略的规则:基线窗口内如果发生过响应事件,那段时间的数据必须剔除,否则基线会被上一次的压降拉低,这次的可调容量就被低估。很多平台第一版都没做这个剔除,第二次事件的可调容量直接掉一半。
3.2 响应能力四元组:容量、爬坡率、持续时长、恢复时间
调度真正关心的不是「你能降多少」这一个数,而是一组数。只报容量不报时间的应答,在调度眼里等于没有应答。
| 参数 | 字段 | 单位 | 典型取值 | 定值依据 |
|---|---|---|---|---|
| 可下调容量 | capacity_down_kw | kW | 额定功率的 30%~50% | 扣除最小负载率与同时率 |
| 爬坡率 | ramp_kw_per_min | kW/min | 离心机 5%~8% 额定/min | 受限于出水温度变化速率 |
| 持续时长 | sustain_min | min | 30~120 | 受冷冻水回水温度上限约束 |
| 恢复时长 | recovery_min | min | 通常 ≥ 持续时长 | 主机重新加载 + 水温回稳 |
容量的计算要先落到单机,再往上聚合:
单机可降容量 = 额定功率 × (1 - 最小负载率) × 工况修正系数 系统可降容量 = Σ 单机可降容量 × 同时率 × 参与比例 最终可调容量 = min(系统可降容量, 末端舒适度约束上限, 合同约定上限)工况修正系数按当前负载率插值:负载率 90% 时接近 1.0,负载率 45% 时只剩 0.3 左右。同时率取 0.7~0.85,因为不可能所有机组都同时降到极限,总有几台在检修、在备用、在保护。参与比例是最容易漏的一项——如果站里只有 60% 的机组接入了控制系统,剩下 40% 手动操作的根本动不了,这个比例必须乘进去。
爬坡率的定值不能拍脑袋。离心机降载太快会触发防喘振保护直接停机,看起来功率掉得很漂亮,其实是被动跳机,恢复时启动电流冲击更大。一般按额定功率的 5%~8% 每分钟来控制,变频螺杆机可以到 10%。这个参数最好由厂家给,拿不到就按保守值取。
持续时长受冷冻水回水温度卡死。主机降载后制冷量下降,建筑负荷不变,回水温度会缓慢上升,升到设定上限(通常是 13~15℃)就不能再降了。这个时间在负荷率高的中午可能只有 30 min,在夜间能拉到 2 h 以上。所以sustain_min必须随工况刷新,写死一个 60 min 是在给自己埋雷。
恢复时长经常被当成可选项省掉,结果事件结束后主机紧急加载,功率冲得比响应前还高,峰谷差反而拉大。这个字段的价值在于让调度知道「什么时候可以再调你一次」,没有它,连续两个间隔 30 min 的事件就能把设备玩坏。
3.3 一段可直接跑的基线计算脚本
下面这段脚本做三件事:按相似日规则筛出参考日、算基线、结合机组实时数据算最终可调容量。输入是历史负荷 CSV 和机组清单,输出直接是应答报文需要的 JSON 结构。
import pandas as pd import numpy as np # 历史负荷表:timestamp, site_id, load_kw, temp_c, is_holiday, is_event HIST_PATH = "load_history.csv" TEMP_TOL = 2.0 # 相似日温度容差,℃ SIMILAR_DAYS = 5 # 参与均值的相似日天数 MIN_SIMILAR = 3 # 低于此值退回事件前基线法 PRE_WINDOW_MIN = 60 # 事件前基线窗口,分钟 SIMULTANEITY = 0.80 # 同时率 PARTICIPATION = 0.60 # 可控机组占比 def pick_similar_days(df, event_date, event_temp): """筛出气象相近、非节假日、未发生响应的历史日""" hist = df[(df["timestamp"].dt.date < event_date) & (df["is_holiday"] == 0) & (df["is_event"] == 0)].copy() daily_temp = hist.groupby(hist["timestamp"].dt.date)["temp_c"].mean() cand = daily_temp[(daily_temp - event_temp).abs() <= TEMP_TOL].index return sorted(cand, reverse=True)[:SIMILAR_DAYS] def baseline_similar_day(df, event_start, event_temp): days = pick_similar_days(df, event_start.date(), event_temp) if len(days) < MIN_SIMILAR: return None, days # 取各参考日事件时段的同位置均值 mask = df["timestamp"].dt.date.isin(days) & \ (df["timestamp"].dt.hour == event_start.hour) return float(df.loc[mask, "load_kw"].mean()), days def baseline_pre_event(df, event_start): """事件前窗口均值,剔除离群点(超过 3 倍标准差)""" window = df[(df["timestamp"] >= event_start - pd.Timedelta(minutes=PRE_WINDOW_MIN)) & (df["timestamp"] < event_start)] if window.empty: return None mu, sd = window["load_kw"].mean(), window["load_kw"].std() clean = window[window["load_kw"].sub(mu).abs() <= 3 * sd] return float(clean["load_kw"].mean()) def flexible_capacity(units): """units 为机组清单,每项含 rated_kw / load_ratio / min_load_ratio / controlled""" total = 0.0 for u in units: if not u["controlled"]: continue # 工况修正:负载率越接近最小负载率,可降空间越小 headroom = max(u["load_ratio"] - u["min_load_ratio"], 0.0) corr = min(headroom / (1 - u["min_load_ratio"]), 1.0) total += u["rated_kw"] * (1 - u["min_load_ratio"]) * corr return round(total * SIMULTANEITY * PARTICIPATION, 1) if __name__ == "__main__": df = pd.read_csv(HIST_PATH) df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True).dt.tz_convert("Asia/Shanghai") event_start = pd.Timestamp("2024-07-18 14:00", tz="Asia/Shanghai") event_temp = 34.5 base_sim, days = baseline_similar_day(df, event_start, event_temp) base_pre = baseline_pre_event(df, event_start) # 双轨交叉校验:偏差超 15% 以事件前基线为准并告警 if base_sim and abs(base_sim - base_pre) / base_pre > 0.15: print(f"[WARN] 基线偏差过大 sim={base_sim} pre={base_pre}") baseline = base_pre else: baseline = base_sim or base_pre units = [{"rated_kw": 350, "load_ratio": 0.77, "min_load_ratio": 0.30, "controlled": True}, {"rated_kw": 350, "load_ratio": 0.62, "min_load_ratio": 0.30, "controlled": True}, {"rated_kw": 500, "load_ratio": 0.00, "min_load_ratio": 0.30, "controlled": False}] print({"baseline_kw": round(baseline, 1), "capacity_down_kw": flexible_capacity(units), "similar_days": [str(d) for d in days]})代码里几个参数直接决定结果的可信度。TEMP_TOL设 2℃ 是权衡出来的:收紧到 1℃,过渡季经常凑不够相似日;放宽到 3℃,相似日的负荷水平已经失真。MIN_SIMILAR设 3 是底线,少于三天均值就失去统计意义,宁可退回事件前基线。PRE_WINDOW_MIN取 60 min,太短抗不住单点异常,太长会把工况变化前的数据混进来。SIMULTANEITY和PARTICIPATION这两个系数是容量虚高的主要来源,直接乘在总量上,保守取值不会错。
flexible_capacity里那个corr修正项是关键。很多人算法写的是rated_kw × (1 - min_load_ratio),不看当前负载率,结果一台在 40% 负载运行的机组和一台在 90% 负载运行的机组算出同样的可降容量,实际前者的可降空间已经接近零。加上这一项之后,可调容量的波动会更贴近真实工况,也更容易解释给调度听。
4. 事件怎么走完全程:从邀约到核验的消息流与状态机
4.1 六个状态与两条岔路:事件生命周期怎么定义才不丢事件
一条事件从发出到归档,状态至少要管住六个节点,外加两个异常分支。状态定义不清楚,最常见的结果是同一个事件在聚合商侧显示「执行中」、在调度侧显示「已取消」,这一段时间里空调在白白降载,钱却结不到。
| 状态 | 触发条件 | 允许的下一步 | 超时处理 |
|---|---|---|---|
| idle | 无事件 | offered / command | — |
| offered | 收到邀约 | accepted / rejected | 5 min 未应答自动 rejected |
| accepted | 确认参与 | running / cancelled | 事件开始时刻自动进入 running |
| running | 进入执行窗口 | completed / cancelled | 结束时刻自动进入 completed |
| completed | 执行窗口结束 | verified | 24 h 内完成核验 |
| verified | 核验完成 | 归档 | — |
offered这一步的超时处理是很容易漏的。调度发出邀约后等不到应答,会认为资源不可用,把你从本次调用名单里划掉;而你这边可能只是消息丢了或者网关重启,事件根本没收到。加一个 5 min 的本地定时器,超时自动回rejected并附原因码,比默默超时好得多。
cancelled是另一条岔路,可以发生在accepted之后、running之中。取消之后必须有恢复动作,不能停在半路——模型里recovery_min这个字段就是给取消和结束两种情况共用的。
状态机最好在代码里实现成显式转移表,而不是一串 if-else。转移表可以直接生成状态图给对接方看,减少扯皮。
消息主题设计遵循「方向/类型/对象」三段式:
dr/event/{aggregator_id}/{resource_id} # 下行:事件邀约与指令 dr/ack/{resource_id}/{event_id} # 上行:应答与状态变更 dr/report/{resource_id}/power # 上行:功率与运行数据 dr/report/{resource_id}/flexibility # 上行:可调能力刷新event_id由发起方生成,全局唯一,所有上行消息必须携带,靠它把一条事件的全部往来串起来。断线重连之后按event_id补拉未确认消息,这条规则要在协议层写死,否则网关重启一次就丢一批事件。
4.2 指令下发与点位映射:一张表把模型字段落到楼控寄存器
模型描述的是「是什么」,指令执行要解决的是「动哪个点」。这两者之间靠点位映射表连起来。这张表维护得好不好,直接决定项目是三天上线还是三个月上线。
| 模型字段 | 点位地址 | 读写 | 数据类型 | 换算 | 采集周期 |
|---|---|---|---|---|---|
| runtime.active_power_kw | AI-40001 | R | float32 | ×0.1 | 5 s |
| runtime.run_state | DI-10012 | R | bool | 0=停 1=运行 | 5 s |
| runtime.fault_code | AI-40020 | R | uint16 | 原值 | 15 s |
| flexibility.capacity_down_kw | — | R | float32 | 计算得出 | 600 s |
| control.load_limit_cmd | AO-40100 | W | float32 | ÷100 | 事件触发 |
| control.start_stop_cmd | DO-10050 | W | bool | 1=启 0=停 | 事件触发 |
几个实操细节。寄存器地址有两种编号习惯,从 0 开始和从 1 开始,差一位就能让你读出一堆垃圾数据,接入前一定拿单机调试工具验证一次。float32 占两个连续寄存器,字节序有大端小端之分,读到 1e-38 这种离谱值基本就是字节序反了,交换一下高低字再试。批量读要合并连续地址,一次读 20 个寄存器比读 20 次单寄存器快一个数量级,网关的并发能力是有限的。
写指令的优先级必须高于任何自动逻辑。楼控系统里往往有厂家写的 PID 或者群控策略,你写下去的限载指令可能下一秒就被群控改回去。做法是在网关侧做写保持,周期性重写目标值,同时在模型里增加一个control_owner字段标明当前控制权归属,抢控制权的时候有据可查。
指令下发到状态反馈之间一定有延迟,冷水机组响应时间通常在 30 s 到 2 min 之间。确认逻辑不能是「写成功就算执行成功」,必须看状态反馈或者功率变化。我的做法是下发后 3 min 内轮询功率,下降超过目标值的 30% 才算执行生效,否则重试一次,两次失败上报execute_failed。
4.3 反馈与计量:怎么证明自己真的降了
核验环节只认数据,不认故事。反馈上报要满足三个条件:时间对得齐、边界说得清、质量码有得看。
时间对齐分两层。采集时刻是网关读上来的时间,入库时刻是平台收到的时间,两个都要存。做功率积分算电量的时候用采集时刻,做故障排查的时候看入库时刻,差个几秒到几分钟很常见。全链路统一用带时区的 ISO8601,网关侧配 NTP 同步,偏差超过 5 s 就告警。
计量边界要在模型里显式声明,不能默认。「系统层总有功」到底包含哪些设备,是只算主机,还是主机加水泵加冷却塔,还是连末端一起算。这个边界必须和结算用的电表边界完全一致,否则就是开篇那个 200 kW 对 80 kW 的局面。常见做法是在系统对象上加一个metering_scope字段,列出参与计量的机组 ID 列表,核验时按这个列表求和。
质量码是很多人省掉的一项。数据从网关到平台,中间可能经过协议转换、消息队列、入库清洗,任何一环出问题都可能产生看起来正常实际错误的数。上报时带good / uncertain / bad三档,核验方可以选择只采信good的数据,减少争议。bad的数据不要直接丢,留着做故障分析。
反馈频率建议执行期内 15 s 一次,执行期外 60 s 一次。太密没有收益,太疏则功率积分误差变大。上报内容至少包含:总功率、各机组功率、室温、供回水温度、当前状态、质量码。
5. 避坑:信息模型落地时最容易翻车的五个地方
5.1 上报可调 500 kW,核验只认 120 kW
现象:平台侧显示可调容量 500 kW,事件执行后电网计量结算只按 120 kW 计算,差距四倍。
原因:容量按铭牌额定功率直接加总算的,没有扣最小负载率、没有乘同时率、没有考虑可控机组占比。更隐蔽的一层是计量边界不一致——上报的容量覆盖整个冷冻站,但结算电表只挂在两台主机上,水泵和冷却塔的功率根本不在计量范围内。
解决:可调容量必须从计量边界内反推,先确定哪些设备在表计范围内,再逐台算可降容量,最后乘同时率和参与比例。上线前用前 15 min 实测功率做一次校核,上报值不超过实测值的 60%,这个经验系数能把大部分虚高挡在门外。
5.2 事件刚下发,总功率不降反升
现象:限载指令下发后 2 分钟内,站总功率从 620 kW 涨到 680 kW,执行方一脸茫然。
原因:多台机组同时降载,冷冻水供水温度快速上升,群控逻辑判断「冷量不足」,自动投入备用机组;或者水泵变频降频后供回水压差不足,末端阀门全开,流量上来反而让主机加载。这类联锁关系在模型里完全没有表达。
解决:模型里增加interlock对象,描述机组之间、主机与水泵之间的联锁规则;同时增加max_concurrent_units字段,限制同一时刻允许调节的台数。执行策略改为分步下发,先降末端风量、再降水泵频率、最后动主机,每步间隔 2~3 min,给系统留出缓冲。
5.3 基线曲线整体平移了 8 小时
现象:核验时发现基线曲线和实际负荷曲线形状几乎一样,但整体错开 8 小时,怎么算都对不上。
原因:设备侧网关用的是本地时间且不带时区标识,平台按 UTC 解析,正好差 8 小时。更麻烦的一种是网关做了时区转换但没改时间戳字符串,只在显示层加了偏移,入库的数据本身就是错的。
解决:全链路强制 ISO8601 带时区偏移,禁止上报无时区的裸时间。网关统一配 NTP,平台侧入库前做一次时区归一化。历史数据补采时要带原始时区信息,不能用「当前时区」重新解释旧数据。
5.4 「运行状态」和「启停指令」映射到了同一个字段
现象:下发停机指令,写入回执成功,平台标记为「执行成功」,但机组实际还在运行,功率一点没降。
原因:点位表里把 DI(状态反馈)和 DO(控制输出)混成了一个模型字段。写指令写的是 DO,回执只表示「写操作完成」,不代表设备动作了。有些网关甚至会把写入值直接回读成状态,制造出「已执行」的假象。
解决:模型里读写彻底分离,命令字段统一加_cmd后缀,状态字段统一加_state后缀,两者不允许同名。执行确认必须读状态反馈或看功率变化,不能看写入回执。这条规矩写进接入规范里,比事后排查省事得多。
5.5 执行 40 分钟后室温越界,被投诉
现象:响应执行到第 40 分钟,多个房间室温升到 28℃ 以上,投诉电话直接打到物业。
原因:模型里只描述了设备能力,没有描述舒适度约束。调度侧拿到「可降 300 kW、可持续 120 min」的应答,按满额调用,全程没有人知道室温上限是 26℃。
解决:在末端层增加comfort_limit对象,上报室温上下限、当前室温、设定值。计算可调容量时把舒适度作为硬约束参与取小,执行期间室温逼近上限自动触发容量回收,并在反馈里上报comfort_margin字段。这一条不加,短期可能多拿一点补偿,长期一定把客户得罪光。
6. 上线前怎么验证这套模型是活的:三层回放与一次小步试投放
模型写完、接口调通,离能上线还差一截。我在几个项目里摸索出一套三层验证法,按成本从低到高排,能把大部分问题挡在现场之前。
第一层是离线数据回放。把历史负荷数据按时间戳喂给模型计算模块,检查可调容量曲线是否平滑、有没有跳变到零或者突然翻倍的时刻。跳变通常意味着某台机组的状态字段解析错了,或者相似日筛选逻辑选进了事件日。这一层不需要任何硬件,一晚上能跑几十种工况组合。
第二层是半实物回放。用一台网关接上模拟从站,把真实点位表灌进去,让平台按完整事件流程走一遍:邀约、确认、下发、反馈、结束、核验。重点看三个地方——指令下发到状态变化的时间差、断线重连后的消息补齐、写保持与群控抢控制权的表现。这一层能抓出八成以上的通信问题。
第三层是现场小步试投放。先只接一台机组、只调用 5% 的容量、持续 15 min,全程盯功率曲线和室温。确认无异常后,再按 10%、20% 逐级放大。这个阶段最忌讳一上来就满额调用,一次翻车可能让客户对整个需求响应业务失去信心。
| 验证项 | 判据 | 常见失败原因 |
|---|---|---|
| 可调容量波动 | 15 min 内变化不超过 20% | 工况修正系数没生效 |
| 指令响应时延 | < 3 min 功率出现可测变化 | 写保持周期过长 |
| 基线复算一致性 | 同一事件两次算偏差 < 5% | 相似日集合不稳定 |
| 断线恢复 | 重连后 60 s 内补齐未确认事件 | 无 event_id 补拉机制 |
| 舒适度约束 | 执行全程室温不越上限 | 约束字段未参与容量取小 |
有个技巧值得单说:把每一次事件的完整报文留档,包括原始下行消息、上行应答、执行期功率序列、核验结果。攒够十几条之后,用同样的数据回放模型,看能不能复现当时的可调容量和核验结论。复现不出来,说明模型里还有依赖外部状态的隐含假设,这种假设在换季或者换设备之后一定出问题。
我自己踩得最深的一次,是把可调容量当成了静态配置写进数据库,夏天上线一切正常,到了十月负荷率掉到 40%,平台还在按夏天的值应答,结果连续三次事件执行不到位,被调度方降级处理。后来改成滚动刷新加事件前强制刷新,再没出过类似问题。这套东西没有一劳永逸的配置,只有跟着工况走的模型,希望帮到你。
本文还有配套的精品资源,点击获取