Files
home-automation/docs/meter-epochs.md
T

168 lines
10 KiB
Markdown
Raw Normal View History

# 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)。
## M8Source binding 与热力 Meter
M8 将“协议连接”和物理 Meter 分开:`MeterSource` 产生稳定 channel`MeterSourceBinding``[started_at, ended_at)` 内把 channel 接到一个 Meter epoch。DSMR electricity 与 WarmteLink heating `GJ`、hot_water `m³` 都使用这条链。
- 正常成本周期的两端必须解析到**同一** Meter 和 binding;跨 epoch、跨 binding、无 binding、读数陈旧或质量不可接纳时一律 degraded,不跨累计域相减。
- source switch 是关闭/新建 binding,不创建假 Meter swap;实际换表才创建新的 Meter epoch。对于 thermalheating 与 hot_water 独立换表,热力组合 HA identity 因任一 UUID 改变而更新。
- `meter_cost_period` 为 heating 与 hot_water 保存 15 分钟 Decimal quantity/cost、binding、合同版本、price snapshot 和 degraded reason。固定费是合同级日汇总,只计一次。
部署与回滚串口 source 参见 [`warmtelink-energy.md`](./warmtelink-energy.md);保留旧 source/binding/history 可使审计和重算可重复,不能通过删除历史来“修复”边界周期。
## 生命周期操作与 stranded binding 恢复
M8-R08/R09 为 Meter 与 binding 增加了显式的生命周期操作。所有时间都由前端按本地日期时间输入,再按既有
Principle-A 约定交给后端;未来时间会被拒绝。
- **Close Meter**:只能关闭 active Meter。该 Meter 与它的所有 open binding 在同一个 `ended_at`、同一
事务中关闭;关闭后该 commodity 没有 active Meter。
- **Unbind**:可对任意 open binding 执行,只写 binding 的 `ended_at`,绝不删除历史。已关闭 Meter 上残留的
open binding 也可在 UI 中解绑,默认关闭时间为该 Meter 的 `ended_at`
- **Transfer**:以单个原子请求切换 source channel,不采用浏览器端“先关闭再新建”的两步操作。同一 Meter
的 source switch 在同一 `effective_at` 关闭旧 binding、开启新 binding。失败时 binding、Meter 与受影响的
成本重算全部回滚,不会显示或留下部分成功。
新建或更新 binding 必须完整落在所属 Meter epoch 内:
`meter.started_at <= binding.started_at < binding.ended_at <= meter.ended_at`Meter 已关闭时);
open-ended binding 只允许属于 active Meter。Close、Unbind、Transfer 与声明 Meter 都从最早受影响边界重算到
当前时间;electricity 与 heating/hot-water 分别使用对应的成本引擎,重算失败时整笔生命周期变更回滚。数据库
提交成功后才会 best-effort 重新发布 HA discovery;发布失败不会伪装成持久化失败。
### 换表自动交接与人工恢复
声明 `reason=meter_swap` 的新 Meter 时,若未选择 channel 且旧 active Meter 恰好有一条唯一、单位兼容的
open binding,系统会在新 Meter 起点自动把该 channel 原子交接过去。存在多个候选或时间线歧义时,声明会
fail closed 并整体回滚。不是这种唯一自动交接的声明,也会在新边界关闭旧 Meter 的 open binding,避免产生新的
“closed Meter + open binding”。
旧版本或历史异常可能已经留下 stranded binding:前一块 closed Meter 仍有 open DSMR/WarmteLink binding
而当前 active Meter 没有 binding。不要修改数据库、不要跑 migration,也不会在启动时自动修复。请在
**Energy → Meters** 使用该 stranded binding 的 **Recover binding** 操作:选择当前 active Meter 与兼容
channel,提交一次 Transfer。来源必须是同 commodity、唯一且紧邻的前一块 closed Meter;旧 binding 固定在旧
Meter 的 `ended_at` 结束,新 binding 从选择的 `effective_at` 开始。默认是新 Meter 的 `started_at`;选择更晚
时间是允许的,但 UI 会提示这段明确的 unbound gap。非前序 Meter、单位不匹配、channel 在无关区间被占用或歧义
都会被拒绝,不会误关历史。
人工 walkthrough 只能使用开发库里**已经存在且由用户报告的** stranded row。不得为了演示通过 SQL、API、
脚本、migration 或直接改数据库制造这种历史异常;隔离开发库中没有该 row 时,记录此项为 `N/A/blocked` 即可。
Close、Unbind、same-Meter Transfer、Recover binding 与 meter-swap handoff 都会改变 Meter 或 binding 状态,
因此应作为彼此独立的验收场景:每个场景使用各自满足前置条件的 Meter/binding,或在执行前恢复独立前置状态,
不能把它们串成会互相破坏前提的单一故事。
上述恢复不新增 Alembic migration、不执行启动修复,也不删除 Meter、binding、reading 或成本历史;它只把用户
确认的时间线修正为可审计的闭区间。
## 非目标(本里程碑不做)
- Gas 计费 strategy。
- "家庭(home)"分组实体;多合同时间线积分。
- 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。
- `last_reset` 信号(消除 HA 长期统计 blip)。
> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)