看板左上角的总功率是 3.8 kW,比昨天同一时刻低了 8%,绿色的趋势线看着很舒服。可往下看,厨房区域的卡片是黄的,上面标着"洗碗机运行中"。
用户的疑问很合理:总量都下降了,还有什么好提示的?
把采样翻出来才看明白。昨天这个点是 4.1 kW,其中客厅空调占了 1.5 kW;今天空调没开,客厅掉到 1.2 kW。省下来的电被厨房吃掉了——洗碗机今天在同一时段启动了,功率 0.8 kW。总量因为这个此消彼长下降了,但厨房确实出现了一台平时不该出现在这个时段的大功率设备。
所以"总量下降"和"每个区域正常"是两件事。看板的异常提示盯的是区域和设备的偏离,跟总量没什么关系。
功率要按来源拆开,不能只记一个总数
第一版的采样只存了一个总功率。这个数据结构没法回答"今天的下降是真的省电,还是光伏出力变大了"。
光伏和储能接进来之后,同一时刻家里有三路能量在动:电网取电、光伏自用、电池放电或充电。总负荷是三者叠加的结果:
exportinterfaceRoomLoad{roomId:stringwatts:number}exportinterfacePowerSample{capturedAt:numbergridW:number// 从电网取电,正数表示消耗solarW:number// 光伏自用部分,恒为正batteryW:number// 正数为充电,负数为放电rooms:RoomLoad[]}exportfunctionhomeLoad(sample:PowerSample):number{// 电池充电时这部分不算家庭负荷,所以取负号把它还原成负荷口径returnsample.gridW+sample.solarW-sample.batteryW}batteryW的符号约定必须在项目里统一。电池充电时它记正数,家庭负荷要减掉这部分,因为它没有真正被用掉;放电时记负数,减掉负数等于加回来,正好把电池供给的那部分算进负荷。这个符号在早期版本里被两个模块用了不同的约定,界面上出现过"总负荷比电网取电还低"的显示。
界面上的"光伏供给 62%"来自另一组计算:
exportfunctionsolarShare(sample:PowerSample):number{constload:number=homeLoad(sample)if(load<=0){return0}constshare:number=(sample.solarW/load)*100// 光伏可能向电网反送,这里只表示自用占比,上限取 100returnMath.min(100,share)}基线不能用昨天,要用同一时段的长期分布
最早的异常判定很直觉:拿今天和昨天比,差得多了就报。这个做法在住家里几乎不可用。洗衣服、做饭、洗澡都有星期规律,工作日和周末的差异能到两三倍。用昨天当基线,周二的用电在周一面前永远是异常。
改成按"星期几 + 小时"建槽位,一周 168 个槽:
exportinterfaceBaselineSlot{slotIndex:number// 0 到 167,等于 星期数 * 24 + 小时meanW:number// 该槽位的历史均值stdW:number// 标准差samples:number// 参与统计的样本数}exportinterfaceAnomalyVerdict{abnormal:booleanreason:stringdeviationW:number}constMIN_SAMPLES:number=4constZ_THRESHOLD:number=2.5constMIN_ABSOLUTE_W:number=300exportfunctiondetectAnomaly(slot:BaselineSlot,actualW:number):AnomalyVerdict{constdeviation:number=actualW-slot.meanW// 样本太少就不判定,避免刚装上的设备天天被标异常if(slot.samples<MIN_SAMPLES){return{abnormal:false,reason:'基线样本不足',deviationW:deviation}}// 绝对偏离太小就不报,小功率设备的相对波动没有意义if(Math.abs(deviation)<MIN_ABSOLUTE_W){return{abnormal:false,reason:'偏离在正常范围内',deviationW:deviation}}constsafeStd:number=slot.stdW<1?1:slot.stdWconstz:number=Math.abs(deviation)/safeStdif(z<Z_THRESHOLD){return{abnormal:false,reason:'波动未超过历史范围',deviationW:deviation}}return{abnormal:true,reason:deviation>0?'高于同时段基线':'低于同时段基线',deviationW:deviation}}三个条件必须同时满足才算异常,缺一个都会产生误报。
样本量门槛来自实际体验。新设备接入后的头两天,基线里只有两三个样本,某个样本恰好是待机状态,均值被拉得很低,之后设备正常工作就被一直判定为"高于基线 400%"。加了这个门槛,头两天安静地学习,第三天开始才给出提示。
绝对偏离门槛解决的是小功率设备的问题。路由器平时 8 W,某天因为固件升级跑到 12 W,相对变化 50%,但图上看不出任何意义。300 W 这个值是根据家里几个主要电器的实际波动定的。
标准差取了下限 1 W。历史样本全都一样的时候标准差会算出 0,除法直接炸掉。
Z_THRESHOLD定在 2.5 而不是常见的 1.96,是有意调松的。1.96 对应 95% 置信,家用场景下一天下来会报出五到八条提示,用户很快就学会无视它们。2.5 大约对应 99%,实测一周只会报出两三条真正值得看的。
分区判定要独立跑一遍
有了区域和设备的功率,就能解释为什么总量下降还会报异常。判定是逐个区域独立做的,不看总量:
exportinterfaceRoomVerdict{roomId:stringabnormal:booleanreason:stringdeviationW:number}exportfunctionjudgeRooms(rooms:RoomLoad[],baselines:Map<string,BaselineSlot>,now:number):RoomVerdict[]{constslotIndex:number=slotOf(now)returnrooms.map((room:RoomLoad)=>{constslot:BaselineSlot|undefined=baselines.get(room.roomId)if(slot===undefined){return{roomId:room.roomId,abnormal:false,reason:'暂无基线',deviationW:0}}constshifted:BaselineSlot={...slot,slotIndex}constverdict:AnomalyVerdict=detectAnomaly(shifted,room.watts)return{roomId:room.roomId,abnormal:verdict.abnormal,reason:verdict.reason,deviationW:verdict.deviationW}})}一个区域大幅下降、另一个区域大幅上升,两者的偏离在总量里互相抵消,可分区判定各自都能看出来。这正是"总用电下降了却还在提示异常"的成因。
界面上客厅卡片显示"较昨日下降 8%",用的是环比;它的异常判定用的是同时段基线。两个数字不一样是正常的,缺一个都会让用户困惑:只给环比,解释不了为什么降了还报警;只给基线偏离,用户又不知道跟自己印象里的昨天差多少。
目录结构
EnergyHub/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 能源流向与区域负荷卡片 │ ├─ features/ │ │ ├─ PowerModel.ets # 采样、负荷与基线结构 │ │ ├─ BaselineLearner.ets # 168 槽位基线的累积 │ │ └─ AnomalyJudge.ets # 偏离判定与分区归因 │ ├─ entryability/EntryAbility.ets │ └─ adapters/ │ ├─ MeterAdapter.ets # 电表与光伏逆变器读数 │ └─ BatteryAdapter.ets # 储能状态 ├─ entry/src/main/resources/ └─ build-profile.json5BaselineLearner每天凌晨用前一天的数据更新一次槽位,白天只读。基线如果在白天滚动更新,异常判定会被自己刚才的判定结果带偏——判定为异常的高功率采样如果也进了基线,这条基线会慢慢爬高,之后同样的用电就不报警了。
界面上的"今日节省 ¥8.40"也是估算值。它用当日自用光伏量乘以当地电价得到,在分时电价地区这个数字只能算个大概。真要准确结算,得引入分时电价表和上网电价,那是另一套逻辑,不该混在实时看板里算。
数据只在本机处理,不上传云端,历史采样按天滚动保留最近 90 天。这些设置都放在系统设置页,用户可以单独关掉某个区域的异常提示。