看板左上角的总功率是 3.8 kW,比昨天同一时刻低了 8%,绿色的趋势线看着很舒服。可往下看,厨房区域的卡片是黄的,上面标着"洗碗机运行中"。
用户的疑问很合理:总量都下降了,还有什么好提示的?
把采样翻出来才看明白。昨天这个点是 4.1 kW,其中客厅空调占了 1.5 kW;今天空调没开,客厅掉到 1.2 kW。省下来的电被厨房吃掉了------洗碗机今天在同一时段启动了,功率 0.8 kW。总量因为这个此消彼长下降了,但厨房确实出现了一台平时不该出现在这个时段的大功率设备。
所以"总量下降"和"每个区域正常"是两件事。看板的异常提示盯的是区域和设备的偏离,跟总量没什么关系。
功率要按来源拆开,不能只记一个总数
第一版的采样只存了一个总功率。这个数据结构没法回答"今天的下降是真的省电,还是光伏出力变大了"。
光伏和储能接进来之后,同一时刻家里有三路能量在动:电网取电、光伏自用、电池放电或充电。总负荷是三者叠加的结果:
ts
export interface RoomLoad {
roomId: string
watts: number
}
export interface PowerSample {
capturedAt: number
gridW: number // 从电网取电,正数表示消耗
solarW: number // 光伏自用部分,恒为正
batteryW: number // 正数为充电,负数为放电
rooms: RoomLoad[]
}
export function homeLoad(sample: PowerSample): number {
// 电池充电时这部分不算家庭负荷,所以取负号把它还原成负荷口径
return sample.gridW + sample.solarW - sample.batteryW
}
batteryW 的符号约定必须在项目里统一。电池充电时它记正数,家庭负荷要减掉这部分,因为它没有真正被用掉;放电时记负数,减掉负数等于加回来,正好把电池供给的那部分算进负荷。这个符号在早期版本里被两个模块用了不同的约定,界面上出现过"总负荷比电网取电还低"的显示。
界面上的"光伏供给 62%"来自另一组计算:
ts
export function solarShare(sample: PowerSample): number {
const load: number = homeLoad(sample)
if (load <= 0) {
return 0
}
const share: number = (sample.solarW / load) * 100
// 光伏可能向电网反送,这里只表示自用占比,上限取 100
return Math.min(100, share)
}
基线不能用昨天,要用同一时段的长期分布
最早的异常判定很直觉:拿今天和昨天比,差得多了就报。这个做法在住家里几乎不可用。洗衣服、做饭、洗澡都有星期规律,工作日和周末的差异能到两三倍。用昨天当基线,周二的用电在周一面前永远是异常。
改成按"星期几 + 小时"建槽位,一周 168 个槽:
ts
export interface BaselineSlot {
slotIndex: number // 0 到 167,等于 星期数 * 24 + 小时
meanW: number // 该槽位的历史均值
stdW: number // 标准差
samples: number // 参与统计的样本数
}
export interface AnomalyVerdict {
abnormal: boolean
reason: string
deviationW: number
}
const MIN_SAMPLES: number = 4
const Z_THRESHOLD: number = 2.5
const MIN_ABSOLUTE_W: number = 300
export function detectAnomaly(slot: BaselineSlot, actualW: number): AnomalyVerdict {
const deviation: 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 }
}
const safeStd: number = slot.stdW < 1 ? 1 : slot.stdW
const z: number = Math.abs(deviation) / safeStd
if (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%,实测一周只会报出两三条真正值得看的。


分区判定要独立跑一遍
有了区域和设备的功率,就能解释为什么总量下降还会报异常。判定是逐个区域独立做的,不看总量:
ts
export interface RoomVerdict {
roomId: string
abnormal: boolean
reason: string
deviationW: number
}
export function judgeRooms(
rooms: RoomLoad[],
baselines: Map<string, BaselineSlot>,
now: number
): RoomVerdict[] {
const slotIndex: number = slotOf(now)
return rooms.map((room: RoomLoad) => {
const slot: BaselineSlot | undefined = baselines.get(room.roomId)
if (slot === undefined) {
return { roomId: room.roomId, abnormal: false, reason: '暂无基线', deviationW: 0 }
}
const shifted: BaselineSlot = { ...slot, slotIndex }
const verdict: AnomalyVerdict = detectAnomaly(shifted, room.watts)
return {
roomId: room.roomId,
abnormal: verdict.abnormal,
reason: verdict.reason,
deviationW: verdict.deviationW
}
})
}
一个区域大幅下降、另一个区域大幅上升,两者的偏离在总量里互相抵消,可分区判定各自都能看出来。这正是"总用电下降了却还在提示异常"的成因。
界面上客厅卡片显示"较昨日下降 8%",用的是环比;它的异常判定用的是同时段基线。两个数字不一样是正常的,缺一个都会让用户困惑:只给环比,解释不了为什么降了还报警;只给基线偏离,用户又不知道跟自己印象里的昨天差多少。
目录结构
text
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.json5
BaselineLearner 每天凌晨用前一天的数据更新一次槽位,白天只读。基线如果在白天滚动更新,异常判定会被自己刚才的判定结果带偏------判定为异常的高功率采样如果也进了基线,这条基线会慢慢爬高,之后同样的用电就不报警了。
界面上的"今日节省 ¥8.40"也是估算值。它用当日自用光伏量乘以当地电价得到,在分时电价地区这个数字只能算个大概。真要准确结算,得引入分时电价表和上网电价,那是另一套逻辑,不该混在实时看板里算。
数据只在本机处理,不上传云端,历史采样按天滚动保留最近 90 天。这些设置都放在系统设置页,用户可以单独关掉某个区域的异常提示。