diff --git a/docs/design/m7-meter-epochs-archival.md b/docs/design/m7-meter-epochs-archival.md new file mode 100644 index 0000000..05e9eee --- /dev/null +++ b/docs/design/m7-meter-epochs-archival.md @@ -0,0 +1,320 @@ +# 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**: `todo` +- **Depends**: none +- **Context**: 引入 Meter 实体的数据地基;为现有数据回填初始表。 + +**Files** +- `modify app/models/energy.py`(新增 `Meter`;`EnergyCostPeriod` 加 `meter_id`) +- `create alembic_app/versions/_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 至多一个 active;swap 正确续接区间。 +- [ ] `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)、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**: `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/_*.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 累计实体不出现负/巨额跳变。