# M7 — 电表生命周期 / 换表归档（Meter epochs）

> 协作格式、任务卡结构、校验闸门、数据安全红线见 [`README.md`](./README.md)，本文不再重复。

## 1. 目标

让计费系统正确处理**电表更换**这一**必然事件**：

- 荷兰 2G 智能电表退网后，电网公司**一定**会把表换成 4G 表（同址换表）；搬家继承新表也是同类。
- 计费是**寄存器差值（delta）模型**：每个 15 分钟周期成本 =（周期末读数 − 周期初读数）× 单价。换表后新表寄存器基数与旧表**无关**（可能更高也可能更低），若跨表算 delta 会产出**负成本或巨额假成本**。
- 目标：引入显式的 **Meter（电表）** 概念，标记"某时刻起属于哪一块物理表"，使引擎**永不跨表算 delta**，并让累计量按表归零、历史可追溯可查。

非目标（本里程碑不做，§9 留痕）：gas / 区域供暖的**计费**；"家庭(home)"分组实体；多合同时间线积分；自动识别换表（DSMR 帧无电表序列号，无法自动识别，靠用户显式声明）。

## 2. 现状（实现者可据此工作，不必通读全仓库）

- 计费数据模型（M6）：
  - `dsmr_reading`：整帧 JSON，`recorded_at`(UNIQUE) 去重；含 4 个累计寄存器 `electricity_delivered_1/2`、`electricity_returned_1/2`。**帧内无电表序列号字段**。
  - `energy_cost_period`：每 15 分钟一行，存 `d1/d2/r1/r2_kwh`(delta)、`import_cost/export_revenue/net_cost`、`pricing` 快照、`contract_version_id`、`degraded`、`computed_at`、`period_start`(unique)。
- 计费引擎 `app/services/energy_cost.py`：
  - `register_at(session, boundary)`：取 `recorded_at ≤ boundary` 的最近一条；有 15 分钟 staleness 护栏（超过则返回 None → 周期降级）。
  - `compute_period(session, t0)`：取 t0/t1 两端寄存器，算 delta，按 `contract` 的 strategy 计价；缺读数→降级行(成本 0)。
  - `compute_closed_periods` / `recompute_range`：遍历刻钟边界批量算。
  - `summarize(session, start, end)`：聚合非降级周期电费 + 固定费/抵扣（FU10 已按本地日跨版本积分）。
- 计价策略 `app/integrations/pricing/strategies.py`：`import_cost = Δd1×buy_dal + Δd2×buy_normal` 等，**对 delta 无任何 clamp/sanity 检查**（负 delta → 负成本，超大 delta → 巨额成本）。
- 暴露 `app/integrations/expose.py`：累计 `import_cost_total`/`export_revenue_total` 锚点（FU11）= `max(合同最早 effective_from, 最早非降级 period_start)`；`*_today` 日归零实体（FU11）。
- 合同 `app/services/contracts.py`：`active` 互斥 + 版本时间线 + `active_contract_version_at(ts)`（半开 `[from,to)`）。合同**不引用电表**。
- 配置/迁移：单库 app，Alembic 链 `alembic_app/`；`PRAGMA foreign_keys=ON` 已开（`app/db.py`）。
- 时区：FU10 的 `app/services/timezone.py`（`local_*`，可 monkeypatch）。

## 3. 目标架构

### 3.1 核心概念：Meter（= epoch）

一条 `meter` 记录 = **一块物理电表的一段安装期** `[started_at, ended_at)`。换表 = 关闭当前 active 表（`ended_at=T`）+ 新建一条 `started_at=T` 的表。计费**永不跨 meter 算 delta**：跨表的那个周期按设计降级。

- **每种 `commodity` 至多一个 active 表**（`ended_at IS NULL`）。本里程碑只 `electricity` 参与计费，`commodity` 字段为未来 gas/heating 预留。
- `meter_at(session, ts, commodity="electricity")`：返回 `started_at ≤ ts AND (ended_at IS NULL OR ts < ended_at)` 的那块表（半开区间，仿 `active_contract_version_at`）。
- 计费基准：某表窗口内**最早的一条 `dsmr_reading`**（`recorded_at ≥ started_at`）即基准；用户**只需填 `started_at` 日期，无需知道当前读数**。追溯（`started_at` 在过去）时，有效起点自然 = `max(started_at, 数据起点)`。
- **隔离的前提 = 显式声明**：系统无法自动识别换表（DSMR 帧无序列号），搬家后新旧读数同落 `dsmr_reading`，靠用户**显式新建那块表**才隔离。万一忘了声明：搬家必有的数据空档（staleness）+ delta 护栏（§3.3）保证**不会产出垃圾成本**，只是新数据暂混在旧表 epoch、累计未归零；**事后补声明 + 追溯 `recompute_range`** 即可纠正。

### 3.2 数据模型（新增 1 张表 + 1 列）

`meter` 表（`app/models/energy.py`，单库 app 链）：

| 列 | 类型 | 说明 |
| --- | --- | --- |
| `id` | int PK | 内置自增 ID |
| `label` | str | 人读标签，编址/标识（如 "旧 2G 表 @ Dorpsstraat 1"） |
| `commodity` | str | 默认 `"electricity"`；预留 `gas`/`heating` |
| `started_at` | datetime(UTC) | 安装/继承/搬家起点（可在过去） |
| `ended_at` | datetime(UTC) \| null | null = 当前 active |
| `reason` | str | `initial` / `meter_swap` / `home_move` / `other` |
| `note` | str \| null | 备注 |
| `created_at` | datetime(UTC) | |

`energy_cost_period` 新增 `meter_id`（nullable FK → `meter.id`）：记录该周期归属哪块表，便于审计/按表查询/归档。

### 3.3 计费引擎改造（`energy_cost.py` / `strategies.py` 调用处）

1. `register_at(session, boundary, meter)`：**仅在该 meter 窗口内**取读数（`started_at ≤ recorded_at < ended_at` 且 `≤ boundary` 且过 staleness 护栏）。绝不把旧表读数拉进新表周期。
2. `compute_period(session, t0)`：
   - 查 `m0 = meter_at(t0)`、`m1 = meter_at(t1)`。
   - `m0 is None`（无表覆盖）→ **降级**（不计费）。
   - `m0.id != m1.id`（周期跨表边界）→ **降级**（D5：丢这一个周期可接受）。
   - 否则在 `m0` 窗口内 `register_at` 两端、算 delta、计价；新增写 `period.meter_id = m0.id`。
3. **delta sanity 护栏（D6）**：算出 delta 后，若 `d1/d2/r1/r2` 任一 `< 0`，或任一 `> _MAX_DELTA_KWH`（常量，给一个远超住宅的宽松上限，如每 15 分钟 100 kWh）→ **降级**（兜底表重置/DSMR 回绕/数据毛刺，与换表无关也防住）。
4. `compute_closed_periods` / `recompute_range`：逐周期按 `meter_at` 判定，无需额外结构。

### 3.4 累计语义（per-meter 归零，D2）

- `expose.py` 累计 `import_cost_total`/`export_revenue_total` 锚点改为**当前 active electricity 表的起点**：`anchor = active electricity meter.started_at`（窗口内首条读数自然 ≥ 它）。换表后累计**从零起一段新序列**，不维护偏移、不跨表累加。
- `*_today` 日归零实体不变（其本地今天窗口必落在当前表内）。
- （可选，非核心）暴露"当前电表 label"作为一个文本 sensor，便于 HA 侧识别当前表。
- **已知行为（可接受）**：累计 `_total` 是 `state_class: total`，换表归零时其值会下台阶，HA 长期统计可能在换表那一刻记一次性负 blip。HA 侧自己存旧值做 down-sample——已与用户确认**先这样、观察后按需处理**（`last_reset` 信号见 §9 杠杆，本里程碑不做）。

### 3.5 API（`app/api/routes/api/` + schemas）

- `GET    /api/energy/meters` — 列出全部表（时间线，active 在前/或按 started_at）。
- `POST   /api/energy/meters` — 声明换表/继承：`{label, started_at, reason, note?}`；服务层关闭当前 active 同 commodity 表（`ended_at=started_at`）+ 建新表。`started_at` 必须 `≥` 当前 active 表的 `started_at`（拒绝倒挂，仿合同版本"strictly after"）。
- `PATCH  /api/energy/meters/{id}` — 改 `label`/`note`，或修正 `started_at`（追溯）。
- `POST   /api/energy/meters/{id}/recompute`（或复用现有 recompute）— 追溯变更后对受影响窗口 `recompute_range`，重判跨表周期与归属。
- 改了路由/schema → 重导出 OpenAPI 并提交。

### 3.6 前端（并入 Energy 视图）

- 一个"电表（Meters）"管理区：表的时间线（label / 区间 / active 标记 / reason），"我换了表 / 继承了新表"表单（只填 label + 日期 + reason），编辑 label/日期。
- 追溯保存后给出"已重算受影响周期"的反馈（或提供 recompute 按钮）。
- 前端闸门见 §8 引用。

### 3.7 迁移与回填（数据安全 runbook）

- Alembic（`alembic_app/`）：建 `meter` 表 + 给 `energy_cost_period` 加 `meter_id` 列（nullable）。
- **回填（只增不删、幂等、对账）**：
  1. 若库内已有任何 `dsmr_reading`/`energy_cost_period`，创建一条**初始表** `meter(label="Initial meter", commodity="electricity", started_at = 最早 dsmr_reading.recorded_at（无则最早 period_start，再无则迁移时刻）, ended_at=NULL, reason="initial")`。
  2. 把现有 `energy_cost_period.meter_id` 全部回填为这条初始表的 id。
  3. **对账**：回填后 `meter_id IS NULL` 的非降级周期数必须为 0；对不上立即中止、非零退出。
- **红线**：迁移**不删除/不覆盖**任何 `dsmr_reading`/`energy_cost_period`/旧 `.db`/备份；纯新增表+列+回填。先在备份副本演练再对真实库执行。

## 4. 已锁定决策（讨论后拍板）

- **D1 粒度**：Meter 为一等公民，**不绑定 home**；`label` 编址 + `commodity` 区分品类（每 commodity 一个 active）。
- **D2 累计**：换表后累计量**归零**（锚当前表起点），直接吃 DSMR 累计 delta，不维护偏移。
- **D3 合同耦合**：**不引入 home 实体**。合同与电表是两条独立时间线，合同不引用电表 → 同址换表时合同自动照常套用，无需上层抽象。搬家若换合同：用"给现有合同加 version"保持历史连续（多合同时间线积分留作后续杠杆）。
- **D4 追溯**：`started_at` 可在过去；有效计费起点 = `max(started_at, 数据起点)`；追溯变更触发 `recompute_range`。
- **D5 边界周期**：跨表周期判 **degraded**（可接受，换表常伴随长时间无数据）。
- **D6 delta 护栏**：加（负/异常大 delta → degraded），通用兜底。
- **D7 扩展性**：gas/heating **计费**不在 scope；`commodity` 字段 + 每品类 active 表使未来加表低成本。

## 5. 任务依赖图

```
M7-T01 (meter 表+模型+列+迁移回填)
   ├──► M7-T02 (meter 服务层: swap/close/edit, meter_at, 互斥/校验)
   │       ├──► M7-T03 (计费引擎 meter-aware + delta 护栏 + period.meter_id)
   │       │        └──► M7-T04 (累计 per-meter 归零, expose 锚点)
   │       └──► M7-T05 (meter API + 追溯 recompute + OpenAPI)
   │                └──► M7-T06 (前端电表管理 UI)
   └──────────────────────────────► M7-T07 (文档/roadmap/收尾)
```

## 6. 原子任务（任务卡）

### M7-T01 — `meter` 表 + 模型 + `energy_cost_period.meter_id` + 迁移回填 `[schema]`

- **Status**: `done`
- **Depends**: none
- **Context**: 引入 Meter 实体的数据地基；为现有数据回填初始表。

**Files**
- `modify app/models/energy.py`（新增 `Meter`；`EnergyCostPeriod` 加 `meter_id`）
- `create alembic_app/versions/<rev>_meter_table.py`
- `modify tests/test_energy_models.py`

**Steps**
1. 加 `Meter` 模型（字段见 §3.2）；`EnergyCostPeriod` 加 nullable `meter_id` FK。
2. 写迁移：建表 + 加列；按 §3.7 回填初始表并回填 `meter_id`；带对账（不一致则 raise）。
3. 测试：模型字段、active-per-commodity 约束的服务层留给 T02；这里测建表/列/回填幂等。

**Out of scope / 不要碰**：计费引擎、expose、API（后续任务）。

**Acceptance criteria**
- [ ] `meter` 表与 `energy_cost_period.meter_id` 建出；迁移可升降级。
- [ ] 有历史数据时回填出恰好一条 `reason="initial"` 的 active 表，且非降级周期 `meter_id` 全部非空。
- [ ] 迁移**不删除/覆盖**任何既有数据；回填幂等。
- [ ] 校验闸门全绿。

**Reviewer checklist**：回填对账逻辑；空库/有数据两种路径；数据安全红线（无删/覆盖）。

### M7-T02 — Meter 服务层（swap/close/edit + `meter_at` + 互斥/校验）

- **Status**: `done`
- **Depends**: M7-T01
- **Context**: 换表/继承的业务逻辑与按时刻查表。

**Files**
- `create app/services/meters.py`
- `create tests/test_meters.py`

**Steps**
1. `meter_at(session, ts, commodity="electricity")`（半开区间）。
2. `declare_meter(session, *, label, started_at, reason, commodity="electricity", note=None)`：校验 `started_at ≥ 当前 active 同 commodity 表的 started_at`（拒绝倒挂）；关闭旧 active（`ended_at=started_at`）+ 建新 active。
3. `list_meters` / `update_meter`（label/note/started_at）；改 started_at 时维持区间自洽。
4. 测试：互斥（每 commodity 单 active）、倒挂拒绝、追溯、`meter_at` 边界。

**Out of scope**：API、引擎、前端。

**Acceptance criteria**
- [ ] 每 commodity 至多一个 active；swap 正确续接区间。
- [ ] `meter_at` 半开区间正确（含边界）。
- [ ] 校验闸门全绿。

**Reviewer checklist**：互斥与续接的事务正确性；追溯改 started_at 的区间一致性。

### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id`

- **Status**: `done`
- **Depends**: M7-T02
- **Context**: 让计费永不跨表算 delta，并兜底异常 delta。

**Files**
- `modify app/services/energy_cost.py`
- `modify tests/test_energy_cost.py`

**Steps**
1. `register_at` 限定在传入 meter 的窗口内取读数。
2. `compute_period`：按 §3.3 判定 `m0/m1`；无表/跨表 → 降级；否则算 delta、写 `meter_id`。
3. 加 `_MAX_DELTA_KWH` 常量与护栏：任一寄存器 delta `<0` 或 `>上限` → 降级。
4. `compute_closed_periods`/`recompute_range` 逐周期 meter 判定。
5. 测试：同表内正确；跨表周期降级；负/超大 delta 降级；无 active 表降级。

**Out of scope**：expose 锚点（T04）、API（T05）。

**Acceptance criteria**
- [ ] 跨 meter 边界周期 = degraded；无表覆盖 = degraded。
- [ ] 负 delta / 超 `_MAX_DELTA_KWH` = degraded，绝不产负/巨额成本。
- [ ] 同表内 delta 计费与归属 `meter_id` 正确。
- [ ] 校验闸门全绿。

**Reviewer checklist**：staleness × meter 窗口的交互；护栏阈值是否远超住宅、不会误伤真实用量；recompute 重判归属。

### M7-T04 — 累计 per-meter 归零（expose 锚点）

- **Status**: `done`
- **Depends**: M7-T03
- **Context**: 换表后 MQTT 累计量从零起算。

**Files**
- `modify app/integrations/expose.py`
- `modify tests/test_energy_expose.py`

**Steps**
1. 累计 `import_cost_total`/`export_revenue_total` 锚点改为**当前 active electricity 表的 `started_at`**（替换 FU11 的 `max(合同, 首周期)`）。
2. `*_today` 不变；确认无非降级周期/无 active 表时仍 `None` 保护。
3.（可选）暴露当前电表 label 文本实体。
4. 测试：换表后累计仅含当前表；anchor 取当前表起点。

**Out of scope**：API/前端。

**Acceptance criteria**
- [ ] 累计量锚 = 当前 active electricity meter 起点；换表归零。
- [ ] None 保护分支保留；daily 实体不受影响。
- [ ] 校验闸门全绿。

**Reviewer checklist**：与 FU11 行为差异是否符合 D2；无表/空数据分支。

### M7-T05 — Meter API + 追溯 recompute + OpenAPI

- **Status**: `done`
- **Depends**: M7-T02
- **Context**: 暴露电表 CRUD 与换表声明；追溯后重算。

**Files**
- `create app/api/routes/api/meters.py`
- `create app/schemas/meter.py`
- `modify app/api/routes/...`（注册路由）
- `modify openapi/openapi.json`, `openapi/openapi.yaml`
- `create tests/test_api_meters.py`

**Steps**
1. `GET/POST/PATCH /api/energy/meters`（+ 追溯触发 `recompute_range`）。
2. 鉴权沿用 `require_session`。
3. `python scripts/export_openapi.py` 并提交 `openapi/`。
4. 测试：列出/声明换表/编辑/倒挂拒绝/追溯重算。

**Out of scope**：前端。

**Acceptance criteria**
- [ ] 端点行为符合 §3.5；追溯变更触发受影响窗口重算。
- [ ] `git diff --exit-code openapi/` 干净（已重导出提交）。
- [ ] 校验闸门全绿。

**Reviewer checklist**：错误码（倒挂/不存在）；recompute 范围是否覆盖受影响周期。

### M7-T06 — 前端电表管理 UI

- **Status**: `done`
- **Depends**: M7-T05
- **Context**: 让用户在 Energy 页声明换表、查看电表时间线。

**Files**
- `create frontend/src/energy/MeterManager.tsx`
- `modify frontend/src/energy/EnergyPage.tsx`, `frontend/src/energy/hooks.ts`
- `modify frontend/src/energy/*.test.tsx`
- `modify frontend/src/api/schema.d.ts`（`npm run codegen`）

**Steps**
1. `npm run codegen` 拉新端点类型。
2. 电表时间线 + "换表/继承"表单（label + 日期 + reason）+ 编辑。
3. 追溯保存后反馈"已重算"。
4. 前端闸门（§8）。

**Out of scope**：后端。

**Acceptance criteria**
- [ ] 能列出/声明换表/编辑；UI 合理。
- [ ] 前端 lint/typecheck/test/build 全绿；`schema.d.ts` 已 codegen。

**Reviewer checklist**：日期→后端时区语义（沿用 FU10 本地午夜 naive 约定）；空状态/加载/错误。

### M7-T07 — 文档 / OpenAPI / roadmap 收尾

- **Status**: `done`
- **Depends**: M7-T01..T06
- **Context**: 把 M7 落档、更新 roadmap 与索引。

**Files**
- `modify docs/roadmap.md`（新增 M7 条目，标完成）
- `modify docs/design/README.md`（索引加 m7）
- `modify docs/design/m7-meter-epochs-archival.md`（Status 收尾）
- `modify docs/*`（如新增 meter 模块说明，按需）

**Acceptance criteria**
- [ ] roadmap/README/本文 Status 一致；OpenAPI 已是最新并提交。
- [ ] 校验闸门全绿。

## 7. 构建上下文完整性（M1 教训）

- 本里程碑只**新增** `app/services/meters.py`、`app/api/routes/api/meters.py`、`app/schemas/meter.py`、`alembic_app/versions/<rev>_*.py` 与前端文件；均落在现有 `Dockerfile` 的 `COPY app ./app` / `COPY alembic_app ./alembic_app` / 前端构建阶段覆盖范围内，**无新增顶层目录**。
- `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 应仍绿；删/移文件时按 README 规则 grep 构建清单（本里程碑预期无删除）。

## 8. 前端校验闸门

`frontend/` 下：`npm run lint && npm run typecheck && npm run test && npm run build`；改了端点/schema 还需 `npm run codegen` 并提交 `schema.d.ts`。

## 9. 后续杠杆（本里程碑不做，留痕）

- **Home/Site 分组**：若将来要"一个地址下多品类表合并出账 + 整屋归档"，再加薄分组层（可仅 `site_label` 字段，不必新实体）。
- **多合同时间线积分**：让 `summarize` 跨多个非重叠合同积分固定费/抵扣，免去搬家"加 version"的折中。
- **换表时给累计实体发 `last_reset` 信号**：消除 HA 长期统计在归零时刻的一次性负 blip（需给 expose/discovery 加 `last_reset` 管线，FU11 当时为省事未做）。已与用户确认本里程碑**先不做**，先观察、需要时再加。
- **Gas / 区域供暖计费**：`commodity!="electricity"` 的 strategy 与寄存器映射。
- **当前电表 label 暴露给 HA**、按表/按地址的成本报表分段。

## 10. 里程碑完成定义（DoD）

✅ **已完成**（M7-T01..T07 全部 done）

- `meter` 数据地基 + 回填上线；计费引擎永不跨表算 delta，跨表/无表/异常 delta 一律降级。
- 累计量按当前表归零；追溯换表可重算。
- Meter CRUD API + 前端管理 UI 可用；OpenAPI/schema 已同步。
- 全程数据安全红线无违反；pytest/ruff/前端闸门全绿。
- 人工 walkthrough：声明一次（含追溯）换表 → 旧表周期保留、跨表周期降级、新表从零累计、HA 累计实体不出现负/巨额跳变。
