Files
home-automation/docs/design/m7-meter-epochs-archival.md
T
tliu93 e3d0d17cac
frontend / frontend (push) Successful in 2m10s
pytest / test (push) Successful in 8m34s
M7-T07: finalize M7 docs (roadmap entry, design index, status done, meter-epochs module doc)
2026-06-25 17:01:54 +02:00

20 KiB
Raw Permalink Blame History

M7 — 电表生命周期 / 换表归档(Meter epochs

协作格式、任务卡结构、校验闸门、数据安全红线见 README.md,本文不再重复。

1. 目标

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

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

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

2. 现状(实现者可据此工作,不必通读全仓库)

  • 计费数据模型(M6):
    • dsmr_reading:整帧 JSONrecorded_at(UNIQUE) 去重;含 4 个累计寄存器 electricity_delivered_1/2electricity_returned_1/2帧内无电表序列号字段
    • energy_cost_period:每 15 分钟一行,存 d1/d2/r1/r2_kwh(delta)、import_cost/export_revenue/net_costpricing 快照、contract_version_iddegradedcomputed_atperiod_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.pyimport_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.pyactive 互斥 + 版本时间线 + active_contract_version_at(ts)(半开 [from,to))。合同不引用电表
  • 配置/迁移:单库 appAlembic 链 alembic_app/PRAGMA foreign_keys=ON 已开(app/db.py)。
  • 时区:FU10 的 app/services/timezone.pylocal_*,可 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_readingrecorded_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_idnullable 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 侧识别当前表。
  • 已知行为(可接受):累计 _totalstate_class: total,换表归零时其值会下台阶,HA 长期统计可能在换表那一刻记一次性负 blip。HA 侧自己存旧值做 down-sample——已与用户确认先这样、观察后按需处理last_reset 信号见 §9 杠杆,本里程碑不做)。

3.5 APIapp/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)

  • Alembicalembic_app/):建 meter 表 + 给 energy_cost_periodmeter_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 为一等公民,不绑定 homelabel 编址 + 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 计费不在 scopecommodity 字段 + 每品类 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(新增 MeterEnergyCostPeriodmeter_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(拒绝倒挂);关闭旧 activeended_at=started_at+ 建新 active。
  3. list_meters / update_meterlabel/note/started_at);改 started_at 时维持区间自洽。
  4. 测试:互斥(每 commodity 单 active)、倒挂拒绝、追溯、meter_at 边界。

Out of scopeAPI、引擎、前端。

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 scopeexpose 锚点(T04)、APIT05)。

Acceptance criteria

  • 跨 meter 边界周期 = degraded;无表覆盖 = degraded。
  • 负 delta / 超 _MAX_DELTA_KWH = degraded,绝不产负/巨额成本。
  • 同表内 delta 计费与归属 meter_id 正确。
  • 校验闸门全绿。

Reviewer checkliststaleness × 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 文本实体。
  3. 测试:换表后累计仅含当前表;anchor 取当前表起点。

Out of scopeAPI/前端。

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.tsnpm 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.mdStatus 收尾)
  • modify docs/*(如新增 meter 模块说明,按需)

Acceptance criteria

  • roadmap/README/本文 Status 一致;OpenAPI 已是最新并提交。
  • 校验闸门全绿。

7. 构建上下文完整性(M1 教训)

  • 本里程碑只新增 app/services/meters.pyapp/api/routes/api/meters.pyapp/schemas/meter.pyalembic_app/versions/<rev>_*.py 与前端文件;均落在现有 DockerfileCOPY 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 累计实体不出现负/巨额跳变。