From e3d0d17cac82d4dbab4e3bb37f80cca2b9081ff9 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 25 Jun 2026 16:56:51 +0200 Subject: [PATCH] M7-T07: finalize M7 docs (roadmap entry, design index, status done, meter-epochs module doc) --- docs/design/README.md | 1 + docs/design/m7-meter-epochs-archival.md | 16 ++-- docs/meter-epochs.md | 114 ++++++++++++++++++++++++ docs/roadmap.md | 45 +++++++++- 4 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 docs/meter-epochs.md diff --git a/docs/design/README.md b/docs/design/README.md index 7baae35..eb72672 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -8,6 +8,7 @@ - [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做** - [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏) - [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺 +- [`m7-meter-epochs-archival.md`](./m7-meter-epochs-archival.md) — 电表生命周期 / 换表归档(Meter epochs) 本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。 diff --git a/docs/design/m7-meter-epochs-archival.md b/docs/design/m7-meter-epochs-archival.md index 05e9eee..366d3ef 100644 --- a/docs/design/m7-meter-epochs-archival.md +++ b/docs/design/m7-meter-epochs-archival.md @@ -123,7 +123,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T01 — `meter` 表 + 模型 + `energy_cost_period.meter_id` + 迁移回填 `[schema]` -- **Status**: `todo` +- **Status**: `done` - **Depends**: none - **Context**: 引入 Meter 实体的数据地基;为现有数据回填初始表。 @@ -149,7 +149,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T02 — Meter 服务层(swap/close/edit + `meter_at` + 互斥/校验) -- **Status**: `todo` +- **Status**: `done` - **Depends**: M7-T01 - **Context**: 换表/继承的业务逻辑与按时刻查表。 @@ -174,7 +174,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id` -- **Status**: `todo` +- **Status**: `done` - **Depends**: M7-T02 - **Context**: 让计费永不跨表算 delta,并兜底异常 delta。 @@ -201,7 +201,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T04 — 累计 per-meter 归零(expose 锚点) -- **Status**: `todo` +- **Status**: `done` - **Depends**: M7-T03 - **Context**: 换表后 MQTT 累计量从零起算。 @@ -226,7 +226,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T05 — Meter API + 追溯 recompute + OpenAPI -- **Status**: `todo` +- **Status**: `done` - **Depends**: M7-T02 - **Context**: 暴露电表 CRUD 与换表声明;追溯后重算。 @@ -254,7 +254,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T06 — 前端电表管理 UI -- **Status**: `todo` +- **Status**: `done` - **Depends**: M7-T05 - **Context**: 让用户在 Energy 页声明换表、查看电表时间线。 @@ -280,7 +280,7 @@ M7-T01 (meter 表+模型+列+迁移回填) ### M7-T07 — 文档 / OpenAPI / roadmap 收尾 -- **Status**: `todo` +- **Status**: `done` - **Depends**: M7-T01..T06 - **Context**: 把 M7 落档、更新 roadmap 与索引。 @@ -313,6 +313,8 @@ M7-T01 (meter 表+模型+列+迁移回填) ## 10. 里程碑完成定义(DoD) +✅ **已完成**(M7-T01..T07 全部 done) + - `meter` 数据地基 + 回填上线;计费引擎永不跨表算 delta,跨表/无表/异常 delta 一律降级。 - 累计量按当前表归零;追溯换表可重算。 - Meter CRUD API + 前端管理 UI 可用;OpenAPI/schema 已同步。 diff --git a/docs/meter-epochs.md b/docs/meter-epochs.md new file mode 100644 index 0000000..3754ece --- /dev/null +++ b/docs/meter-epochs.md @@ -0,0 +1,114 @@ +# Meter Epochs(电表生命周期 / 换表归档) + +本文档说明 **Meter epoch** 概念、换表声明流程、计费隔离行为、累计量归零、追溯重算,以及与 HA 累计 blip 的已知行为。 + +## 为什么需要 Meter epoch + +计费引擎采用**寄存器差值(delta)模型**:每 15 分钟成本 =(周期末读数 − 周期初读数)× 单价。荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类场景。 + +问题在于:**新表的寄存器基数与旧表无关**,若跨表算 delta,会产出负成本或巨额假成本。Meter epoch 让引擎知道"某时刻起属于哪一块物理表",从而永不跨表算 delta。 + +## 核心概念 + +一条 `meter` 记录 = **一块物理电表的一段安装期** `[started_at, ended_at)`: + +- `started_at`:表安装/继承/搬家的起点(可在过去,追溯声明)。 +- `ended_at`:null = 当前 active;非 null = 已退役表的结束时刻。 +- `label`:人读标签,便于识别(如 "旧 2G 表 @ Dorpsstraat 1")。 +- `reason`:`initial`(初始建档)/ `meter_swap`(换表)/ `home_move`(搬家)/ `other`。 +- `commodity`:默认 `electricity`;为未来 gas/heating 预留,每 commodity 至多一个 active 表。 + +**换表** = 关闭旧 active 表(`ended_at = T`)+ 新建 `started_at = T` 的表。系统**无法自动识别换表**(DSMR 帧无电表序列号),靠用户**显式声明**才能隔离。 + +## 计费隔离行为 + +每个 15 分钟计费周期,引擎先查该周期两端(t0/t1)分别属于哪块表: + +| 情况 | 结果 | +| --- | --- | +| 同一块表(正常)| 在表窗口内取读数、算 delta、正常计费 | +| 无表覆盖(未声明)| **降级**(成本置 0,`degraded=true`) | +| 跨表边界(t0 ≠ t1 的表)| **降级**(丢这一个周期,可接受;换表常伴随长时间无数据) | +| delta < 0 或 > 100 kWh/15min | **降级**(兜底异常,如 DSMR 回绕/毛刺,与换表无关也防住) | + +**忘了声明换表怎么办**:搬家必有的数据空档(staleness)+ delta 护栏保证不会产出垃圾成本,只是新数据暂混在旧表 epoch、累计未归零。事后补声明 + 追溯重算即可纠正(见下方"追溯换表")。 + +## 换表声明流程 + +### 通过前端(Energy 页 → Meters tab) + +1. 在 Energy 页切到 **Meters** tab,查看当前电表时间线。 +2. 点击"声明换表 / 继承新表",填写: + - **Label**:新表的人读标签(如 "4G 新表 2026") + - **安装日期**:新表的 `started_at`(可选择历史日期追溯) + - **Reason**:`meter_swap` / `home_move` / `other` +3. 保存后,旧表自动关闭(`ended_at = 填写日期`),新表成为 active。 +4. 如填写的是过去日期,系统自动触发受影响窗口的重算(`recompute_range`),跨表周期变为降级,新表内周期重新归属。 + +### 通过 API + +```http +POST /api/energy/meters +Content-Type: application/json + +{ + "label": "4G 新表 2026", + "started_at": "2026-07-01T00:00:00", + "reason": "meter_swap", + "note": "荷兰电网公司换装" +} +``` + +`started_at` 必须 `≥` 当前 active 表的 `started_at`(拒绝倒挂)。 + +查询所有表(时间线): + +```http +GET /api/energy/meters +``` + +编辑 label / 备注 / 修正日期: + +```http +PATCH /api/energy/meters/{id} +``` + +## 追溯换表(started_at 在过去) + +若换表日期在过去但当时忘了声明: + +1. 声明新表,填写过去的安装日期。 +2. 系统自动对 `[started_at, now]` 范围内的周期触发 `recompute_range`: + - 跨表边界周期 → 降级。 + - 归属新表窗口内的周期 → `meter_id` 更新为新表。 +3. MQTT 累计量锚点切换至新表起点,下一次 expose 推送时累计量从新表起点重算。 + +## 累计量归零(per-meter) + +MQTT 上报的累计成本实体(`import_cost_total` / `export_revenue_total`)锚点 = **当前 active electricity 表的 `started_at`**。换表后: + +- 累计量从新表安装时刻起重新累加,不跨表维护历史偏移。 +- 日归零实体(`*_today`)不受影响(其窗口必落在当前表内)。 + +### 已知行为:HA 长期统计的一次性负 blip + +累计实体 `state_class: total`,换表归零时其值会**下台阶**。Home Assistant 长期统计(energy dashboard / statistics)可能在换表那一刻记录一次性负 blip。 + +这是**已知、可接受的行为**:HA 自身存旧值做 down-sample,短期 blip 不影响日常电费读数。已与用户确认先这样观察。如需消除 blip,可在未来通过 `last_reset` 信号通知 HA(见设计文档 §9 后续杠杆,本里程碑不做)。 + +## 回填(迁移时的初始表) + +首次升级到含 M7 迁移的版本时: + +- 若库内已有任何 `dsmr_reading` / `energy_cost_period`,迁移自动创建一条 `reason="initial"` 的初始表(`started_at = 最早 dsmr_reading.recorded_at`),并回填现有 `energy_cost_period.meter_id`。 +- 若是全新空库,初始表不创建(无历史数据)。 +- 回填幂等:重复跑迁移不会创建多条初始表;回填后对账(非降级周期 `meter_id IS NULL` 数必须为 0)。 + +## 非目标(本里程碑不做) + +- Gas / 区域供暖的计费(`commodity != "electricity"` 的 strategy)。 +- "家庭(home)"分组实体;多合同时间线积分。 +- 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。 +- `last_reset` 信号(消除 HA 长期统计 blip)。 + +> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 0cfa5d6..93e3d6b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ 本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。 -> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。 +> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。 ## 当前基线(v1.0.3) @@ -39,6 +39,7 @@ | **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) | | **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 | | **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 | +| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。 @@ -214,6 +215,48 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用, --- +## M7 — 电表生命周期 / 换表归档(✅ 已完成) + +### 目标 + +让计费系统正确处理**电表更换**这一必然事件:荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类。引入显式的 **Meter(电表)epoch** 概念,标记"某时刻起属于哪一块物理表",使引擎**永不跨表算 delta**,并让累计量按表归零、历史可追溯可查。 + +### 关键能力 + +- **Meter epoch 数据模型**:一条 `meter` 记录 = 一块物理电表的一段安装期 `[started_at, ended_at)`。换表 = 关闭旧 active 表(`ended_at=T`)+ 新建 `started_at=T` 的表。每 `commodity` 至多一个 active 表;`commodity` 字段预留 `gas`/`heating`。 +- **计费永不跨表**:`compute_period` 先查 t0/t1 两端的 `meter_at`;无表覆盖或跨表边界 → **降级**(成本置 0),绝不产负成本或巨额假成本。 +- **delta 护栏**:任一寄存器 delta `< 0` 或 `> _MAX_DELTA_KWH`(100 kWh/15min,远超住宅用量)→ 降级。兜底表重置 / DSMR 回绕 / 数据毛刺,与换表无关的异常也一并防住。 +- **累计按当前表归零(D2)**:`expose.py` 累计 `import_cost_total`/`export_revenue_total` 锚点 = 当前 active electricity meter 的 `started_at`;换表后累计从零起新序列,不维护跨表偏移。 +- **追溯换表可重算**:`started_at` 可在过去;PATCH 修正日期后,触发受影响窗口 `recompute_range` 重判跨表周期归属。 +- **Meter CRUD API + 前端管理 UI**:`GET/POST/PATCH /api/energy/meters`(+ 追溯 recompute);Energy 页新增 Meters tab,展示电表时间线,支持"换表/继承"表单与编辑。 + +### 新增表与列(单库 app 链,migration `20260625_13_meter_table`) + +| 表/列 | 关键设计 | +| --- | --- | +| `meter` | `id`、`label`、`commodity`(默认 `electricity`)、`started_at`、`ended_at`(null=active)、`reason`(`initial`/`meter_swap`/`home_move`/`other`)、`note`、`created_at` | +| `energy_cost_period.meter_id` | nullable FK → `meter.id`;记录每周期归属,便于审计/按表查询/归档 | + +迁移含回填:有历史数据时自动创建一条 `reason="initial"` 的初始表,并回填现有 `energy_cost_period.meter_id`;幂等 + 对账(非降级周期回填后 `meter_id IS NULL` 数必须为 0)。 + +### 已锁定决策摘要 + +- **D1**:Meter 不绑定 home,`label` 编址 + `commodity` 区分品类。 +- **D2**:换表后累计量归零(锚当前表起点),不维护跨表偏移。 +- **D3**:合同与电表是独立时间线,合同不引用电表;同址换表时合同自动沿用。 +- **D4**:`started_at` 可在过去;有效计费起点 = `max(started_at, 数据起点)`。 +- **D5**:跨表边界周期判 degraded(换表常伴随长时间无数据,可接受)。 +- **D6**:负/异常大 delta → degraded,通用兜底。 +- **D7**:gas/heating 计费不在本里程碑;`commodity` 字段为未来扩展预留。 + +**已知行为(可接受)**:累计 `_total` 是 `state_class: total`,换表归零时 HA 长期统计可能在换表那一刻记一次性负 blip。已与用户确认先这样、观察后按需处理(`last_reset` 信号见设计文档 §9 留痕,本里程碑不做)。 + +> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md) +> +> 模块概念说明:[`docs/meter-epochs.md`](./meter-epochs.md) + +--- + ## M3 — 开放与移动端(远期试水) ### 目标