Files
home-automation/docs/design/m7-meter-epochs-archival.md
T
tliu93 2544514f52
frontend / frontend (push) Successful in 2m6s
pytest / test (push) Successful in 7m51s
M7: add meter-epochs / meter-swap-archival milestone design doc
Design for explicit meter lifecycle (epochs) so the register-difference
billing engine never computes a delta across a meter swap (2G->4G or house
move). Meter-centric (no Home entity); per-meter cumulative reset; retroactive
declaration with recompute; delta sanity guard as backstop. 7 atomic task cards.
2026-06-25 14:23:25 +02:00

321 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)`)。合同**不引用电表**。
- 配置/迁移:单库 appAlembic 链 `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**: `todo`
- **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**: `todo`
- **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 至多一个 activeswap 正确续接区间。
- [ ] `meter_at` 半开区间正确(含边界)。
- [ ] 校验闸门全绿。
**Reviewer checklist**:互斥与续接的事务正确性;追溯改 started_at 的区间一致性。
### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id`
- **Status**: `todo`
- **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)、APIT05)。
**Acceptance criteria**
- [ ] 跨 meter 边界周期 = degraded;无表覆盖 = degraded。
- [ ] 负 delta / 超 `_MAX_DELTA_KWH` = degraded,绝不产负/巨额成本。
- [ ] 同表内 delta 计费与归属 `meter_id` 正确。
- [ ] 校验闸门全绿。
**Reviewer checklist**staleness × meter 窗口的交互;护栏阈值是否远超住宅、不会误伤真实用量;recompute 重判归属。
### M7-T04 — 累计 per-meter 归零(expose 锚点)
- **Status**: `todo`
- **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**: `todo`
- **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**: `todo`
- **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**: `todo`
- **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)
- `meter` 数据地基 + 回填上线;计费引擎永不跨表算 delta,跨表/无表/异常 delta 一律降级。
- 累计量按当前表归零;追溯换表可重算。
- Meter CRUD API + 前端管理 UI 可用;OpenAPI/schema 已同步。
- 全程数据安全红线无违反;pytest/ruff/前端闸门全绿。
- 人工 walkthrough:声明一次(含追溯)换表 → 旧表周期保留、跨表周期降级、新表从零累计、HA 累计实体不出现负/巨额跳变。