# 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)
