# Meter Epochs(电表生命周期 / 换表归档) 本文档说明 **Meter epoch** 概念、换表声明流程、计费隔离行为、累计量归零、追溯重算,以及与 HA 累计 blip 的已知行为。 ## 为什么需要 Meter epoch 计费引擎采用**寄存器差值(delta)模型**:每 15 分钟成本 =(周期末读数 − 周期初读数)× 单价。荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类场景。 问题在于:**新表的寄存器基数与旧表无关**,若跨表算 delta,会产出负成本或巨额假成本。Meter epoch 让引擎知道"某时刻起属于哪一块物理表",从而永不跨表算 delta。 ## 核心概念 一条 `meter` 记录 = **一块物理电表的一段安装期** `[started_at, ended_at)`: - `started_at`:表安装/继承/搬家的起点(可在过去,追溯声明)。 - `ended_at`:null = 当前 active;非 null = 已退役表的结束时刻。 - `label`:人读标签,便于识别(如 "旧 2G 表 @ Dorpsstraat 1")。 - `reason`:`initial`(初始建档)/ `meter_swap`(换表)/ `home_move`(搬家)/ `other`。 - `commodity`:默认 `electricity`;为未来 gas/heating 预留,每 commodity 至多一个 active 表。 **换表** = 关闭旧 active 表(`ended_at = T`)+ 新建 `started_at = T` 的表。系统**无法自动识别换表**(DSMR 帧无电表序列号),靠用户**显式声明**才能隔离。 ## 计费隔离行为 每个 15 分钟计费周期,引擎先查该周期两端(t0/t1)分别属于哪块表: | 情况 | 结果 | | --- | --- | | 同一块表(正常)| 在表窗口内取读数、算 delta、正常计费 | | 无表覆盖(未声明)| **降级**(成本置 0,`degraded=true`) | | 跨表边界(t0 ≠ t1 的表)| **降级**(丢这一个周期,可接受;换表常伴随长时间无数据) | | delta < 0 或 > 100 kWh/15min | **降级**(兜底异常,如 DSMR 回绕/毛刺,与换表无关也防住) | **忘了声明换表怎么办**:搬家必有的数据空档(staleness)+ delta 护栏保证不会产出垃圾成本,只是新数据暂混在旧表 epoch、累计未归零。事后补声明 + 追溯重算即可纠正(见下方"追溯换表")。 ## 换表声明流程 ### 通过前端(Energy 页 → Meters tab) 1. 在 Energy 页切到 **Meters** tab,查看当前电表时间线。 2. 点击"声明换表 / 继承新表",填写: - **Label**:新表的人读标签(如 "4G 新表 2026") - **安装日期**:新表的 `started_at`(可选择历史日期追溯) - **Reason**:`meter_swap` / `home_move` / `other` 3. 保存后,旧表自动关闭(`ended_at = 填写日期`),新表成为 active。 4. 如填写的是过去日期,系统自动触发受影响窗口的重算(`recompute_range`),跨表周期变为降级,新表内周期重新归属。 ### 通过 API ```http POST /api/energy/meters Content-Type: application/json { "label": "4G 新表 2026", "started_at": "2026-07-01T00:00:00", "reason": "meter_swap", "note": "荷兰电网公司换装" } ``` `started_at` 必须 `≥` 当前 active 表的 `started_at`(拒绝倒挂)。 查询所有表(时间线): ```http GET /api/energy/meters ``` 编辑 label / 备注 / 修正日期: ```http PATCH /api/energy/meters/{id} ``` ## 追溯换表(started_at 在过去) 若换表日期在过去但当时忘了声明: 1. 声明新表,填写过去的安装日期。 2. 系统自动对 `[started_at, now]` 范围内的周期触发 `recompute_range`: - 跨表边界周期 → 降级。 - 归属新表窗口内的周期 → `meter_id` 更新为新表。 3. MQTT 累计量锚点切换至新表起点,下一次 expose 推送时累计量从新表起点重算。 ## 累计量归零(per-meter) MQTT 上报的累计成本实体(`import_cost_total` / `export_revenue_total`)锚点 = **当前 active electricity 表的 `started_at`**。换表后: - 累计量从新表安装时刻起重新累加,不跨表维护历史偏移。 - 日归零实体(`*_today`)不受影响(其窗口必落在当前表内)。 ### 已知行为:HA 长期统计的一次性负 blip 累计实体 `state_class: total`,换表归零时其值会**下台阶**。Home Assistant 长期统计(energy dashboard / statistics)可能在换表那一刻记录一次性负 blip。 这是**已知、可接受的行为**:HA 自身存旧值做 down-sample,短期 blip 不影响日常电费读数。已与用户确认先这样观察。如需消除 blip,可在未来通过 `last_reset` 信号通知 HA(见设计文档 §9 后续杠杆,本里程碑不做)。 ## 回填(迁移时的初始表) 首次升级到含 M7 迁移的版本时: - 若库内已有任何 `dsmr_reading` / `energy_cost_period`,迁移自动创建一条 `reason="initial"` 的初始表(`started_at = 最早 dsmr_reading.recorded_at`),并回填现有 `energy_cost_period.meter_id`。 - 若是全新空库,初始表不创建(无历史数据)。 - 回填幂等:重复跑迁移不会创建多条初始表;回填后对账(非降级周期 `meter_id IS NULL` 数必须为 0)。 ## 非目标(本里程碑不做) - Gas / 区域供暖的计费(`commodity != "electricity"` 的 strategy)。 - "家庭(home)"分组实体;多合同时间线积分。 - 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。 - `last_reset` 信号(消除 HA 长期统计 blip)。 > 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)