Files
home-automation/docs/design/m7-meter-epochs-archival.md
T

323 lines
20 KiB
Markdown
Raw Permalink Normal View History

# 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**: `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 至多一个 activeswap 正确续接区间。
- [ ] `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)、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**: `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 累计实体不出现负/巨额跳变。