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.
19 KiB
M7 — 电表生命周期 / 换表归档(Meter epochs)
协作格式、任务卡结构、校验闸门、数据安全红线见
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 调用处)
register_at(session, boundary, meter):仅在该 meter 窗口内取读数(started_at ≤ recorded_at < ended_at且≤ boundary且过 staleness 护栏)。绝不把旧表读数拉进新表周期。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。
- 查
- delta sanity 护栏(D6):算出 delta 后,若
d1/d2/r1/r2任一< 0,或任一> _MAX_DELTA_KWH(常量,给一个远超住宅的宽松上限,如每 15 分钟 100 kWh)→ 降级(兜底表重置/DSMR 回绕/数据毛刺,与换表无关也防住)。 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)。 - 回填(只增不删、幂等、对账):
- 若库内已有任何
dsmr_reading/energy_cost_period,创建一条初始表meter(label="Initial meter", commodity="electricity", started_at = 最早 dsmr_reading.recorded_at(无则最早 period_start,再无则迁移时刻), ended_at=NULL, reason="initial")。 - 把现有
energy_cost_period.meter_id全部回填为这条初始表的 id。 - 对账:回填后
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.pymodify tests/test_energy_models.py
Steps
- 加
Meter模型(字段见 §3.2);EnergyCostPeriod加 nullablemeter_idFK。 - 写迁移:建表 + 加列;按 §3.7 回填初始表并回填
meter_id;带对账(不一致则 raise)。 - 测试:模型字段、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.pycreate tests/test_meters.py
Steps
meter_at(session, ts, commodity="electricity")(半开区间)。declare_meter(session, *, label, started_at, reason, commodity="electricity", note=None):校验started_at ≥ 当前 active 同 commodity 表的 started_at(拒绝倒挂);关闭旧 active(ended_at=started_at)+ 建新 active。list_meters/update_meter(label/note/started_at);改 started_at 时维持区间自洽。- 测试:互斥(每 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.pymodify tests/test_energy_cost.py
Steps
register_at限定在传入 meter 的窗口内取读数。compute_period:按 §3.3 判定m0/m1;无表/跨表 → 降级;否则算 delta、写meter_id。- 加
_MAX_DELTA_KWH常量与护栏:任一寄存器 delta<0或>上限→ 降级。 compute_closed_periods/recompute_range逐周期 meter 判定。- 测试:同表内正确;跨表周期降级;负/超大 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.pymodify tests/test_energy_expose.py
Steps
- 累计
import_cost_total/export_revenue_total锚点改为当前 active electricity 表的started_at(替换 FU11 的max(合同, 首周期))。 *_today不变;确认无非降级周期/无 active 表时仍None保护。 3.(可选)暴露当前电表 label 文本实体。- 测试:换表后累计仅含当前表;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.pycreate app/schemas/meter.pymodify app/api/routes/...(注册路由)modify openapi/openapi.json,openapi/openapi.yamlcreate tests/test_api_meters.py
Steps
GET/POST/PATCH /api/energy/meters(+ 追溯触发recompute_range)。- 鉴权沿用
require_session。 python scripts/export_openapi.py并提交openapi/。- 测试:列出/声明换表/编辑/倒挂拒绝/追溯重算。
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.tsxmodify frontend/src/energy/EnergyPage.tsx,frontend/src/energy/hooks.tsmodify frontend/src/energy/*.test.tsxmodify frontend/src/api/schema.d.ts(npm run codegen)
Steps
npm run codegen拉新端点类型。- 电表时间线 + "换表/继承"表单(label + 日期 + reason)+ 编辑。
- 追溯保存后反馈"已重算"。
- 前端闸门(§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 累计实体不出现负/巨额跳变。