2026-06-23 19:53:26 +02:00
# M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算(含 Tibber 动态 / 固定合同两种 profile)
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 **M5( MQTT + HA Discovery + expose 框架 + Energy 视图 + Recharts) ** 之上,复用其大部分基建,并把 M5 的"两层模型"( YAML profile 定结构 + DB 行存部署值)从设备采集复制到**电价合同**上。
> **状态**:架构与原子任务已拆到可开工。少数价格常数(卖价残差、能源税年值、双费率寄存器映射)等真实 Tibber token / 账单确认,已落成 §13 的可调值 + TODO,不阻塞实现。
## 1. 目标
把"电价合同"与"DSMR 实时电表数据"接起来,按 **15 分钟**周期算出**实际买电支出 / 卖电收入** ,自己落库留底(不可变、可审计),并通过 MQTT + HA Discovery 反哺 Home Assistant 的 Energy 仪表盘:
1. **通用电价层(两层模型)** :仓库内**只读 YAML profile 定义合同结构**( `manual` 固定/可变费率、`tibber` 动态电价各一个结构);DB 的 ** `EnergyContract` (+ 版本)存可改的数值**( UI 填);**price strategy** 按 `kind` 出价。一次激活一个合同。
2. **DSMR 实时数据接入** :订阅 DSMR Reader 的 `dsmr/json` (每秒一条完整 telegram),**整帧存为 JSON blob、按 10 秒降采样**落库(电、气、各相全存,未来加供暖不改表)。
3. **实时买卖电费计算(两层)** :每 15 分钟用**电表累计寄存器差值** × 当时合同的买/卖价算计量电费(不可变、快照价);日/月/年**汇总**再加固定费、减能源税抵扣。**不做净计量**(saldering 2027 取消)。
4. **反哺 Home Assistant** :复用 M5 expose/Discovery,把当前买/卖价、累计买电支出、累计卖电收入发成 HA 实体(累计用 `total_increasing` )。
5. **前端** :Energy 视图下新增合同管理(按 profile 结构生成表单)+ 价格曲线 + 费用走势/明细 + Tibber 测试。
> **命名分层(延续 M5)**:存储/采集/计算层中性命名(`dsmr_*` / `tibber_price` / `energy_cost_period` / `energy_contract`);面向用户并入既有 **Energy** 视图。
## 2. 现状(实现者可据此工作,不必通读全仓库)
**单库 + Alembic( M5 完成态)**
- `app/db.py` : `class Base(DeclarativeBase)` , cached engine(已开 `journal_mode=WAL` + `foreign_keys=ON` )。
- 单 Alembic 链 `alembic_app/` , **当前 head = `20260622_10_exposed_entities` **; `scripts/app_db_adopt.py` 的 `APP_BASELINE_REVISION` 同。迁移命名 `YYYYMMDD_NN_<desc>.py` ,串 `down_revision` ; `alembic_app/env.py` 逐个 import 模型。
- `modbus_reading` 已确立**通用 JSON payload 遥测**范式(`recorded_at` 真实索引列 + `payload` JSON)——`dsmr_reading` 直接沿用。
- M5 两层模型(`app/integrations/modbus/profiles/*.yaml` 定结构 + `modbus_device` 存部署值 + pydantic 启动期校验 + `decode` )是本里程碑电价层的范本。
**配置系统(扁平 KV,自动渲染)**
- `app/config.py` `Settings(BaseSettings)` ; `app/services/config_page.py` 的 `CONFIG_FIELDS` 注册表 + `build_runtime_settings(session, bootstrap)` ( DB override 合并,**消费方都用它**) + `_settings_payload` (新字段补一行)。前端 `ConfigPage.tsx` 按 section/`input_type` /`secret` 通用渲染,bool 渲染成开关。
- ⚠️ 扁平 KV **装不下"合同清单 + 逐字段数值 + 版本"** ——这些走专用表 + 专用 API + 自定义 UI(仿 M5 `modbus_device` )。
**MQTT(M5,关键:当前只发不收)**
- `app/integrations/mqtt.py` `MqttManager` : `is_configured/is_connected/connect/disconnect/reconnect/publish` , paho `loop_start()` 后台线程,VERSION2 回调。**无 `subscribe` /`on_message` **——M6-T06 在此扩订阅端。
- `app/integrations/expose.py` : `ExposableEntity` ( dataclass, `value_getter: Callable[[Session],Any]` )、`EntityProvider = Protocol(session)->list[ExposableEntity]` 、`register_provider` + `build_catalog` 。已有 `_modbus_provider` 。**M6 加 `_energy_cost_provider` 复用整条 Discovery 链**。
- `app/services/ha_discovery.py` : `build_discovery_payload` /`publish_discovery` /`publish_states` /`publish_device_state` 。`app/models/expose.py` `ExposedEntityToggle` (默认未勾不暴露)。
**后台调度(APScheduler, UTC) **
- `app/main.py` lifespan: `BackgroundScheduler(timezone="UTC")` , `add_job(..., max_instances=1, coalesce=True)` ;同步 wrapper 自管 session、吞异常不崩 job。已有 public-ip / modbus-poll / discovery-state 等 job——**新增 job/订阅不得破坏它们**。
**依赖** : `httpx` ( Tibber GraphQL)、`paho-mqtt` 、`pyyaml` 、`apscheduler` 均已在 `requirements.in/.txt` 。**M6 不新增 Python 依赖**。前端已有 Recharts / TanStack Query / `openapi-fetch` 类型化 client / Mantine。
## 3. 目标架构
### 3.0 数据流总览
```
EnergyContract(active, 版本带生效日期) DSMR Reader
│ kind=manual → 常数双费率 │ MQTT dsmr/json (1s)
│ kind=tibber → API 15min ▼
│ 订阅 + 10s 降采样 → dsmr_reading(整帧 JSON blob)
│ (kind=tibber 时) │
▼ │ 累计寄存器=真值
Tibber GraphQL → tibber_price(15min, 不可变) │
│ ▼
└──────────────► price strategy ◄────── 计费引擎(每 15min 边界)
(manual/tibber 出价) │ 寄存器差 × 价,快照
▼
energy_cost_period(每15min 计量电费, 不可变)
│
┌────────────────────────┼───────────────────────────┐
▼ ▼ ▼
日/月/年汇总(+固定费 −抵扣) expose provider + HA Discovery /api/energy/* + 前端
```
三条核心原则:
1. **算钱用寄存器差值,不用功率积分** :每 15 分钟能量 = 累计寄存器@末 − @初 ,电表真值、与采样频率无关。瞬时功率只用于显示(且 HA 里已有,后端不专门用它算钱)。
2. **结构固定(YAML)、数值可改(UI/DB)、历史不可变(快照 + 版本)** : profile YAML 定合同长什么样;UI 填数值、改价存新版本;算过的周期快照当时的价,永不被回改。
3. **两层费用** :每 15 分钟只算**计量电费**;按天/年的**固定费、能源税抵扣**在**汇总层**加,不污染每周期引擎。三相 / 双费率 / 燃气都靠"通用 blob + profile 结构"容纳,不改表。
### 3.1 两层电价模型(本里程碑地基,仿 M5)
```
┌──────────────────────────────────────────────────────────────┐
│ 知识层(固定,随代码走) 部署层(可改,落 DB,UI 填) │
│ ── pricing profile YAML ── ── EnergyContract 行 ── │
│ • kind=manual / tibber • name( UI 自由填) │
│ • 有哪些字段、单位、计费语义 • kind(UI 选) │
│ • 哪些分档 / 是否动态源 • active(一次一个) │
│ • UI 不可编辑 • currency │
│ ── EnergyContractVersion ── │
│ • effective_from / to │
│ • values(JSON,符合 profile) │
│ • 改价=加新版本,旧版本保留 │
└──────────────────────────────────────────────────────────────┘
│ kind 决定 strategy
▼
price strategy(代码): manual=常数双费率出价; tibber=读 tibber_price 出价
```
- **profile YAML(仓库内只读,定结构)**:`app/integrations/pricing/profiles/manual.yaml` 、`tibber.yaml` ,pydantic 启动期校验。声明该 kind **有哪些字段、单位、计费语义、哪些分档** ——**不放具体数值**。
- **`EnergyContract` 行(部署,UI 填值)**: `name` (自由填,不 hardcode "fixed")、`kind` ( UI 选)、`active` 、`currency` 。
- **`EnergyContractVersion` (版本/时段,可审计)**: `effective_from` /`effective_to` (null=开口) + `values` (JSON,符合 profile 结构)。**改价 = 加一个新版本行,旧版本保留**;"2026 上半年一个价、下半年另一个价" = 同合同两个版本。引擎算某周期时挑**覆盖该时刻的版本**。
- **price strategy(代码,按 kind) **: `manual` 用版本里的双费率常数出价;`tibber` 读 `tibber_price` + 版本里的 energy_tax/sell_adjust 出价。计费引擎对两者一视同仁。
** `manual.yaml` 结构(块状、可读;值在 UI 填)**
```yaml
kind : manual
label : 固定 / 可变费率(NL,双费率)
energy :
dual_tariff : true # 用 delivered_1/2、returned_1/2 分高低谷
buy :
normal : { unit : EUR/kWh } # 高价档(_2)
dal : { unit : EUR/kWh } # 低价档(_1)
sell :
normal : { unit : EUR/kWh } # 回送高价
dal : { unit : EUR/kWh } # 回送低价(现两档同价,仍分开填)
energy_tax : { unit : EUR/kWh } # 能源税,加到买价上(含 VAT)
ode : { unit : EUR/kWh, default : 0 } # 现并入能源税
standing : # 固定费:UI 按月填,内部摊到每天
network_fee : { unit : EUR/month }
management_fee : { unit : EUR/month }
credits :
heffingskorting : { unit : EUR/year } # 能源税抵扣,年度,汇总时扣
```
** `tibber.yaml` 结构**
```yaml
kind : tibber
label : Tibber 动态电价(15 分钟)
energy :
source : tibber_api # buy = total; sell = total − energy_tax − sell_adjust
energy_tax : { unit : EUR/kWh }
sell_adjust : { unit : EUR/kWh, default : 0 }
standing :
management_fee : { unit : EUR/month, default : 5.99 }
network_fee : { unit : EUR/month }
credits :
heffingskorting : { unit : EUR/year }
```
> 所有金额含 VAT(用户口径)。**Tibber token** 不在合同里、走 secret config(凭据归 config)。
### 3.2 DSMR 接入
- **数据源**: DSMR Reader 的 **Telegram JSON** (单 topic `dsmr/json` ,一条 = 一帧完整 telegram,已是解析后的表寄存器值)。**每秒一条**。
- **整帧存 JSON blob**(沿用 `modbus_reading` payload 哲学):**不做字段 allowlist**,整帧存下来(电、气 `extra_device_*` 、各相全要)。理由:现有燃气、未来供暖、三相——blob 全装,**不改表**;读取时按 key 取。
- **实测 JSON 的坑(已确认)**:数值是 **JSON 字符串** ( `"20915.154"` )→ 计费读取时转 **Decimal** ;缺测相位是 ** `null` ** → 视为缺测;`id` (telegram 自增)做幂等去重。
- **降采样**:仅当 `timestamp` 秒数落在 `dsmr_sample_interval_s` (默认 10)整数倍时落盘,其余丢弃(量从 60 行/分降到 ~6 行/分;秒=00 在网格上,每个 15 分钟边界都抓得到)。
- **订阅**:扩 `MqttManager` 订阅端(`on_connect` 里 subscribe、`topic→handler` 、`on_message` 分发,handler 在网络线程内只做"解析+降采样+一次短事务"、吞异常不崩连接)。`dsmr_ingest_enabled` (默认 false, opt-in)。
- **进口/出口总量** = `delivered_1+delivered_2` / `returned_1+returned_2` ; **双费率分档** = `_1` (dal/低) vs `_2` (normal/高)( NL 惯例,§13 确认别接反)。
- **三相 / 燃气 / 供暖**:blob 天然容纳,单相→三相只是多几个 key,不改表(详 §10)。
### 3.3 Tibber 价格接入(仅 `kind=tibber` 时)
- **客户端**: `app/integrations/tibber/client.py` , httpx POST `https://api.tibber.com/v1-beta/gql` , `Bearer <token>` 。
- **15 分钟查询**( introspection + demo 实证):
```graphql
{ viewer { homes { id currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
nodes { startsAt total energy tax currency level }
} } } } }
` ``
- ` Price` 字段:` total`( =energy+tax, **全包价**, demo 已证 ` total==energy+tax`)、` energy`(纯 spot ex-VAT)、` tax`、` startsAt`(含时区偏移)、` currency`、` level`。
- 枚举 **` PriceInfoRangeResolution`** 值 ` DAILY/HOURLY/QUARTER_HOURLY`(不是 ` PriceResolution`);老的 ` priceInfo.range` 已废弃。
- **已验证(demo key) **:返回 96 节点、间隔 15 分钟、` total==energy+tax`;老数据无真 15min 价时 API 把小时价**重复成 4 个刻钟**,当前 NL 数据则是真 15 分钟。**解析不假设固定间隔/节点数**,按 ` startsAt` 逐点存。
- **抓取调度**:启动 + 每日 job 拉「今天+明天」,按 ` starts_at` **upsert** ` tibber_price`(幂等)。次日价约 13:00 CET 发布 → 每日 14:00 CET( 12:00 UTC)抓明天,缺失每小时重试。**仅当 active 合同 kind=tibber 且 token 存在时跑**。
- **连接测试** ` POST /api/energy/tibber/test`:拉一次 current price,三态(success 带当前价 / config-error / failed),仿 M5 ` mqtt/test`。
### 3.4 计费引擎(两层)
**第一层 — 每 15 分钟计量电费(` energy_cost_period`,不可变、快照价)**
- 触发:APScheduler 1 分钟 tick,找"已闭合未算"的 15 分钟周期算(也支持按区间手动重算)。
- 单周期 ` [t0,t1)`( ` t1` 已过):
1. 取各寄存器在 ` t0`/` t1` 的值(` recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**: ` Δd1,Δd2,Δr1,Δr2`。
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
- ` manual`: ` import_cost = Δd1× (buy_dal) + Δd2× (buy_normal)`( ` buy_x = energy_buy_x + energy_tax + ode`);` export_revenue = Δr1× sell_dal + Δr2× sell_normal`。
- ` tibber`:取覆盖 t0 的 ` tibber_price`( ` starts_at ≤ t0` 最近一条);` buy = total`、` sell = total − energy_tax − sell_adjust`; ` import_cost = (Δd1+Δd2)× buy`、` export_revenue = (Δr1+Δr2)× sell`。
3. ` net_cost = import_cost − export_revenue`; **upsert** ` energy_cost_period`, **快照**当时用的价 + ` contract_version_id`。
- 缺价/缺数据:跳过或标 ` degraded`,留待重算。**不做净计量**(进出口分开累加)。
**第二层 — 汇总(日/月/年,读时计算,不存表)**
- ` Σ 周期 net_cost`(区间内)**+ 固定费**( ` network_fee + management_fee` 按月→按天 × 天数)**− 能源税抵扣**(` heffingskorting` 按年→按天 × 天数)= **实际应付**。
- 固定费/抵扣取 active 合同版本的值;UI 按月/按年填,引擎内部摊到每天。
### 3.5 数据模型(新增 5 张表,单库 app 链)
> 单一 P1 智能电表,` dsmr_reading` 不设 device 表;将来第二块表加 ` source` 列即可。
| 表 | 关键列 | 说明 |
| --- | --- | --- |
| **` dsmr_reading`** | ` recorded_at`(idx)、` source_id`(unique, =telegram id)、` payload`(JSON) | 整帧 telegram blob, 10s 降采样 |
| **` energy_contract`** | ` name`、` kind`(manual/tibber)、` active`、` currency`、时间戳 | 合同头;一次一个 active |
| **` energy_contract_version`** | ` contract_id`(FK)、` effective_from`、` effective_to`(null)、` values`(JSON)、` created_at` | 版本/时段;改价加新版本、旧版本保留(审计)|
| **` tibber_price`** | ` starts_at`(unique)、` resolution`、` energy`、` tax`、` total`、` level`、` currency`、` fetched_at` | 15min 价缓存,**不可变**;仅 tibber kind |
| **` energy_cost_period`** | ` period_start`(unique)、` d1/d2/r1/r2_kwh`(周期差)、` import_cost`、` export_revenue`、` net_cost`、` currency`、` pricing`(JSON 快照)、` contract_version_id`(FK)、` degraded`、` computed_at` | 每15min 计量电费,**不可变**(快照价+来源版本)|
- ` energy_cost_period` 存 per-register 差 + 快照价 → 完全可审计、source-agnostic、可重算覆盖。
- FK: ` energy_contract_version.contract_id → energy_contract.id`( ON DELETE RESTRICT);` energy_cost_period.contract_version_id → energy_contract_version.id`( RESTRICT,保审计链)。
### 3.6 不可变 / 审计(贯穿)
1. **算账快照**: ` energy_cost_period` 存死当时实际用的价 + ` contract_version_id`;改合同 / 改价**不回改**已算周期——"以前的价就是以前的价"。重算是**显式 opt-in**( ` POST /recompute`),不自动覆盖历史。
2. **合同版本只增不改**:改价 = 新 ` energy_contract_version`(带生效日期),旧版本保留,全程审计链。
3. **Tibber 价不可变**:过去某 15 分钟的价不会变。
4. 三者叠加 → manual / tibber 都满足审计与不可变。
### 3.7 MQTT / HA expose(复用 M5)
加 ` app/integrations/expose.py::_energy_cost_provider`( ` register_provider`),归一个 HA device( ` identifiers="energy-cost"`):
| key | component | 取值 | 用途 |
| --- | --- | --- | --- |
| ` buy_price_now` | sensor | 当前周期/当前档买价(€/kWh, monetary) | HA 当前单价 |
| ` sell_price_now` | sensor | 当前卖价(€/kWh) | |
| ` import_cost_total` | sensor | 累计 import_cost( ` total_increasing`, monetary, currency) | HA Energy "总成本实体" |
| ` export_revenue_total` | sensor | 累计 export_revenue( ` total_increasing`) | HA Energy 回送补偿 |
- ` value_getter(session)` 从 ` energy_cost_period`(累计/当前)取值。沿用 M5 勾选(默认未勾)、` publish_discovery`、` publish_states`(计费 job 后顺带推 + 周期兜底)。
### 3.8 前端(并入 Energy 视图)
- **合同管理**:列表 + 新建/编辑(**表单按选中 profile 结构渲染字段**:manual 双费率/能源税/固定费/抵扣;tibber token 提示走 Config) + 激活(一次一个 active)+ 改价=新版本(带生效日期)+ 版本历史只读展示。
- **价格曲线**:今天+明天 15min 买/卖价(tibber)或固定档位(manual)折线(复用 ` EnergyCharts`),取 ` /api/energy/prices`。
- **费用走势 + 明细 + 汇总**:当日/月 import_cost/export_revenue/net 走势 + 每 15min 明细 + 汇总卡片(含固定费/抵扣),取 ` /api/energy/costs` + ` /costs/summary`。
- **Tibber 测试**: Config 页一块「Tibber」,三态(仿 MQTT 测试)。
- 全走类型化 client;空/加载/错误态齐全;缺 key 容错。
## 4. API 契约(M6 要落地的端点)
> 全部 ` /api` 前缀、session + CSRF(写)保护、JSON 进出;schema 经 ` scripts/export_openapi.py` 固化入库。
| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 合同 | ` GET /api/energy/contracts` | 列出合同 + active 标记 |
| 合同 | ` POST /api/energy/contracts` | 新建合同(kind + 首版本值,按 profile 校验)|
| 合同 | ` GET /api/energy/contracts/{id}` | 单个合同 + 版本历史 |
| 合同 | ` PATCH /api/energy/contracts/{id}` | 改名 / 激活(一次一个 active)|
| 合同 | ` POST /api/energy/contracts/{id}/versions` | 加新版本(改价,带生效日期)|
| 合同 | ` GET /api/energy/profiles` | 列出 pricing profile 结构(前端按它渲染表单)|
| 价格 | ` GET /api/energy/prices?start&end` | 区间价格点(曲线)|
| 费用 | ` GET /api/energy/costs?start&end` | 区间 ` energy_cost_period`(走势/明细)|
| 费用 | ` GET /api/energy/costs/summary?start&end` | 区间汇总(计量电费 + 固定费 − 抵扣)|
| 费用 | ` POST /api/energy/costs/recompute?start&end` | 幂等重算(补数据/改价后,显式)|
| DSMR | ` GET /api/energy/dsmr/latest` | 最新 ` dsmr_reading` |
| 测试 | ` POST /api/energy/tibber/test` | 试连 Tibber + 拉当前价,三态 |
> DSMR/Tibber 的**标量配置 + token** 复用现有 ` GET/PUT /api/config`(只加 CONFIG_FIELDS)。**价格数值全在合同里**,不进 flat config。
## 5. 已锁定决策(讨论后拍板)
1. **两层电价模型**: profile YAML 定结构(仓库、固定、UI 不可编辑)+ ` EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
2. **kind 不叫 "fixed"**: ` manual`(人工填、可双费率、可带时段)/ ` tibber`( API 动态);合同 ` name` UI 自由填。
3. **买价**: tibber = API ` total`(全包,已证 total=energy+tax);manual = ` energy_buy_档 + energy_tax`。**卖价**: tibber = ` total − energy_tax − sell_adjust`; manual = ` sell_档`(回送价,无能源税)。均含 VAT。
4. **双费率**: manual 用 ` delivered_1/2`、` returned_1/2` 分 dal/normal 计价(` _1`=dal/低、` _2`=normal/高);tibber 求和、15min 价不分档。
5. **两层费用**:每 15min ` energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
7. **DSMR 整帧存 JSON blob**(不做字段 allowlist),10s 降采样;数值转 Decimal、null 容错、` id` 幂等。扩 ` MqttManager` 订阅端。
8. **5 张表**: ` dsmr_reading`、` energy_contract`、` energy_contract_version`、` tibber_price`、` energy_cost_period`。单电表不设 device 表。
9. **不可变 / 审计**:算账快照价 + 合同版本只增不改 + tibber 价天然不可变;重算显式 opt-in。
10. **Tibber 用 httpx + ` priceInfoRange(QUARTER_HOURLY)`**,仅 active=tibber 时抓;token 走 secret config。
11. **复用 M5 expose/Discovery**:加 ` _energy_cost_provider`;累计成本/收入用 ` total_increasing` 喂 HA Energy。
12. **前端并入 Energy 视图**:合同管理表单按 profile 结构渲染;Recharts 画价/费。
13. **opt-in 开关**: ` dsmr_ingest_enabled` 默认 false; Tibber 抓取/计费由 active 合同存在性驱动。
14. **周期边界用 UTC 刻钟**(NL 偏移整小时,本地/UTC 重合)。**不新增依赖**。
> 项目定位:个人自用、家庭特化、不开源——按单用户场景简化,不过度抽象。
## 6. 任务依赖图
` ``
Phase A(地基)
M6-T01 [schema] 5 张表 + 模型 + 迁移
M6-T02 flat 配置(dsmr_* + tibber token/home_id) ← 与 T01 并行
M6-T03 pricing profile 框架(manual.yaml/tibber.yaml + pydantic + strategy 注册表) ← 纯模块,可早做
Phase B(合同 + 数据接入)
M6-T04 EnergyContract CRUD + 版本 + 按 profile 校验(API) (Depends T01,T03)
M6-T05 Tibber 客户端 + 抓价 service + 调度 + tibber/test (Depends T01,T02,T03)
M6-T06 MqttManager 扩订阅 + DSMR ingest( blob/10s) (Depends T01,T02)
Phase C(计算 + 反哺)
M6-T07 计费引擎(每15min 计量 + 汇总)+ 周期 job + 重算 (Depends T01,T03,T04,T05,T06)
└─► M6-T08 energy_cost expose provider + state 发布 (Depends T07)
Phase D( API + 前端)
M6-T09 Energy 数据 API( prices/costs/summary/dsmr-latest/recompute) (Depends T05,T07)
└─► M6-T10 前端:合同管理 + 价格/费用视图 + Tibber 测试 (Depends T04,T09)
收尾
M6-T11 文档 + OpenAPI + roadmap( Depends 全部)
` ``
` T01`、` T02`、` T03` 无前置可先开。
---
## 7. 原子任务(任务卡)
> 后端任务沿用校验闸门(` pytest` / ` ruff` / 改路由或 schema 则 ` export_openapi` 重导出入库);前端闸门见 §8。**不新增依赖**。纯新增、不删/移文件;改 ` app/main.py` lifespan 时不得破坏既有 job/连接。
### M6-T01 — 5 张表与模型 ` [schema]`
2026-06-23 20:33:35 +02:00
- **Status**: ` done` · **Depends**: none
2026-06-23 19:53:26 +02:00
- **Context**: 单库 app 链新增 5 张表(§3.5)。只建 schema + 模型,不写采集/计算/接口。
- **Files**: ` create app/models/energy.py`( ` DsmrReading`/` EnergyContract`/` EnergyContractVersion`/` TibberPrice`/` EnergyCostPeriod`);` create alembic_app/versions/20260623_11_energy_tables.py`; ` modify alembic_app/env.py`、` scripts/app_db_adopt.py`( ` APP_BASELINE_REVISION`→新 head);` create tests/test_energy_models.py`
- **Steps**:
1. 按 §3.5 定义模型(` Mapped[...]`):` DsmrReading`( ` recorded_at` idx、` source_id` unique nullable、` payload` JSON);` EnergyContract`( ` name`/` kind`/` active`/` currency`/时间戳);` EnergyContractVersion`( ` contract_id` FK RESTRICT、` effective_from`/` effective_to` nullable、` values` JSON、` created_at`);` TibberPrice`( ` starts_at` unique、` resolution`、` energy/tax/total` float、` level` nullable、` currency`、` fetched_at`);` EnergyCostPeriod`( ` period_start` unique、` d1/d2/r1/r2_kwh` float、` import_cost/export_revenue/net_cost` float、` currency`、` pricing` JSON、` contract_version_id` FK RESTRICT nullable、` degraded` bool、` computed_at`)。
2. 新 revision: ` down_revision="20260622_10_exposed_entities"`; ` upgrade()` 建 5 表 + 索引/唯一/FK; ` downgrade()` 反向 drop。更新 ` APP_BASELINE_REVISION`。
- **Out of scope**: 不写客户端/采集/计费/接口;不动其它模型。
- **Acceptance criteria**:
- [ ] 全新临时库 upgrade 到 head 含 5 表及约束;` downgrade -1` 干净回滚;链上单 head。
- [ ] FK 为 RESTRICT;唯一/索引正确;` payload`/` values`/` pricing` 为 JSON 列。
- [ ] ` APP_BASELINE_REVISION` == 新 head。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;` recorded_at`/` period_start`/` starts_at` 真实索引或唯一列;` env.py` 已 import 新模型;迁移 ` down_revision` 指当前真实 head;无破坏性操作。
### M6-T02 — flat 配置(DSMR + Tibber 凭据)
2026-06-23 20:43:46 +02:00
- **Status**: ` done` · **Depends**: none
2026-06-23 19:53:26 +02:00
- **Context**: DSMR 订阅设置 + Tibber 凭据接入扁平配置(前端自动渲染)。**价格数值不在这里**(在合同里)。
- **Files**: ` modify app/config.py`、` app/services/config_page.py`( CONFIG_FIELDS + ` _settings_payload`);` modify .env.example`、` tests/test_api_config.py`
- **Steps**: ` Settings` 加 ` dsmr_ingest_enabled: bool=False`、` dsmr_mqtt_topic: str="dsmr/json"`、` dsmr_sample_interval_s: int=10`、` tibber_api_token: str=""`、` tibber_home_id: str=""`; CONFIG_FIELDS 归「DSMR」「Tibber」section, ` tibber_api_token` 为 secret, bool=checkbox、数值=number; ` _settings_payload` 补齐;` .env.example` 加注释样例(默认 off)。
- **Out of scope**: 不建客户端/采集;不动既有字段;不放任何价格常数。
- **Acceptance criteria**:
- [ ] 新项在 ` GET /api/config` 分 section; token 为 secret(回空/留空保留);非法值 422 不写库。
- [ ] 校验闸门全绿(OpenAPI 变化则重导出)。
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;` _settings_payload` 无遗漏;默认 off。
### M6-T03 — pricing profile 框架 + strategy 注册表
2026-06-23 20:58:34 +02:00
- **Status**: ` done` · **Depends**: none
2026-06-23 19:53:26 +02:00
- **Context**: 仓库内 ` manual.yaml`/` tibber.yaml` 定结构(pydantic 校验),加 price strategy 注册表(manual/tibber 出价)。纯模块,单测。
- **Files**: ` create app/integrations/pricing/__init__.py`、` pricing/profiles.py`( pydantic 模型 + ` load_profile`/` list_profiles`/` validate_values`)、` pricing/profiles/manual.yaml`、` pricing/profiles/tibber.yaml`、` pricing/strategies.py`( ` register_strategy`/` get_strategy`、` manual`/` tibber` 实现);` create tests/test_pricing_profiles.py`、` tests/test_pricing_strategies.py`
- **Steps**:
1. ` profiles.py`: pydantic 模型描述 §3.1 结构;` load_profile(kind)`(读 YAML,校验失败抛错);` validate_values(kind, values)`(合同数值是否符合结构)。
2. ` profiles/*.yaml`:按 §3.1 写 manual / tibber 结构。
3. ` strategies.py`:策略接口 ` price_period(deltas, t0, values, session) -> {import_cost, export_revenue, net, pricing_snapshot}`( Decimal):
- ` manual`:双费率公式(§3.4),` buy_x = energy_buy_x + energy_tax + ode`、` sell_x` 直接用。
- ` tibber`:取覆盖 t0 的 ` tibber_price`, ` buy=total`、` sell=total− energy_tax− sell_adjust`,进出口求和。
- **Out of scope**: 不连真实 API( tibber 策略只读 ` tibber_price` 表,由 T05 填);不写采集/调度/接口。
- **Acceptance criteria**:
- [ ] 单测:` load_profile("manual"/"tibber")` 校验通过;缺字段/类型错抛可识别错;` validate_values` 正确判合规。
- [ ] 单测:manual 策略双费率出价正确(` import = Δd1× buy_dal + Δd2× buy_normal` 等);tibber 策略 ` buy=total`、` sell=total− energy_tax− sell_adjust`,负 total 出口得负值。
- [ ] Decimal 计算无 float 误差。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 ` starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API)
2026-06-23 21:19:25 +02:00
- **Status**: ` done` · **Depends**: M6-T01, M6-T03
2026-06-23 19:53:26 +02:00
- **Context**: 合同增删改、版本(改价加新版本、生效日期)、激活(一次一个)、按 profile 校验数值。
- **Files**: ` create app/api/routes/api/energy_contracts.py`、` app/schemas/energy_contract.py`、` app/services/contracts.py`; ` modify app/main.py`(注册路由);` create tests/test_api_energy_contracts.py`
- **Steps**:
1. ` contracts.py` service:建合同(含首版本,` validate_values` 校验)、加版本(带 ` effective_from`,自动闭合上一版本 ` effective_to`)、激活(把其它 active 置 false)、` active_contract_version_at(session, ts)`(取 active 合同在 ts 生效的版本)。
2. 路由:` GET/POST/GET{id}/PATCH{id}`、` POST {id}/versions`、` GET /profiles`(返回结构供前端渲染表单);session+CSRF;数值不合规 422。
- **Out of scope**: 不算费用(T07);不发 MQTT;不写采集。
- **Acceptance criteria**:
- [ ] CRUD + 版本 + 激活行为正确:新建按 profile 校验(不合规 422);加版本自动闭合上一段;激活互斥(同时只一个 active);` GET /profiles` 返回结构。
- [ ] 未登录 401、缺 CSRF 403; schema 经 OpenAPI 固化入库。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 ` validate_values`;路径键用合同 id。
### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
2026-06-23 21:35:29 +02:00
- **Status**: ` done` · **Depends**: M6-T01, M6-T02, M6-T03
2026-06-23 19:53:26 +02:00
- **Context**: httpx 拉 15min 价 upsert ` tibber_price`;启动+每日抓(仅 active=tibber);连接测试。
- **Files**: ` create app/integrations/tibber/__init__.py`、` tibber/client.py`、` app/services/tibber_prices.py`; ` modify app/main.py`(抓价 job);` create tests/test_tibber_client.py`、` tests/test_tibber_prices.py`
- **Steps**:
1. ` client.py`: ` fetch_price_range(token, home_id|None) -> list[PricePoint]`(§3.3 query,httpx 超时/认证失败抛错,不假设固定节点数);` fetch_current_price(...)`(供 test)。
2. ` tibber_prices.py`: ` refresh_prices(session, settings)`(拉今天+明天 upsert,幂等);仅当 active 合同 kind=tibber 且 token 存在时执行。
3. ` main.py`:同步 wrapper + ` IntervalTrigger`, ` max_instances=1, coalesce=True`,吞异常不崩。
- **Out of scope**: 不算费用(T07);不发 MQTT;test 路由在 T09(本任务备 service)。
- **Acceptance criteria**:
- [ ] 单测(httpx mock):解析返回为 PricePoint;认证失败/超时抛可识别异常。
- [ ] 单测:` refresh_prices` 幂等(按 ` starts_at` upsert,不重复插);非 tibber active 时 no-op。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: token 不进日志;httpx 超时;upsert 幂等;时间存 UTC;无对 ` tibber_price` 的破坏性批删。
### M6-T06 — MqttManager 扩订阅 + DSMR ingest
- **Status**: ` todo` · **Depends**: M6-T01, M6-T02
- **Context**: 给只发不收的 ` MqttManager` 扩订阅;DSMR ingest 整帧 blob + 10s 降采样落库。
- **Files**: ` modify app/integrations/mqtt.py`(订阅注册 + on_message 分发);` create app/services/dsmr_ingest.py`; ` modify app/main.py`(启用时注册订阅);` create tests/test_mqtt_subscribe.py`、` tests/test_dsmr_ingest.py`
- **Steps**:
1. ` mqtt.py`: ` subscribe(topic, handler)`( ` on_connect` 里对已注册 topic subscribe, ` on_message` 按 topic 分发,handler 异常吞掉不崩连接);保持既有 publish 不回归。
2. ` dsmr_ingest.py`: ` handle_message(payload_bytes, settings)`: parse JSON → 取 ` timestamp`(UTC) → 降采样(` second % dsmr_sample_interval_s != 0` 丢弃)→ **整帧存 payload**(值原样/Decimal 友好)→ 短 session 写 ` dsmr_reading`( ` source_id`=` id`,存在则跳过)。
3. ` main.py`: ` dsmr_ingest_enabled` 时把 handler 注册到 ` dsmr_mqtt_topic`。
- **Out of scope**: 不算费用(T07);不改既有 publish/discovery 语义;不做字段 allowlist(整帧存)。
- **Acceptance criteria**:
- [ ] 单测:` subscribe` 注册后 ` on_message` 分发到 handler;既有 publish 无回归。
- [ ] 单测:喂样本 JSON(字符串数值、null 相位),秒=00 整帧落盘、秒≠10 整数倍丢弃;同 ` id` 不重插。
- [ ] ` dsmr_ingest_enabled=false` 不订阅/不落盘。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: on_message 网络线程内只做短事务、吞异常不崩连接;整帧存(含 gas/各相);null 容错;` source_id` 幂等;10s 降采样正确。
### M6-T07 — 计费引擎 + 周期 job + 汇总 + 重算
- **Status**: ` todo` · **Depends**: M6-T01, M6-T03, M6-T04, M6-T05, M6-T06
- **Context**: 每 15min 用寄存器差 × active 合同 strategy 算计量电费(快照、不可变);汇总加固定费 − 抵扣;周期 job + 重算。
- **Files**: ` create app/services/energy_cost.py`; ` modify app/main.py`(周期 job);` create tests/test_energy_cost.py`
- **Steps**:
1. ` energy_cost.py`: ` register_at(session, boundary)`(取 ≤boundary 最后一行的 d1/d2/r1/r2, Decimal);` compute_period(session, t0)`(取 active 版本 + strategy( T03/T04)→ 算 → upsert + 快照 ` pricing` + ` contract_version_id`,缺价/缺数据标 ` degraded` 或跳过);` compute_closed_periods(session)`; ` recompute_range(session, start, end)`(幂等覆盖);` summarize(session, start, end)`(Σnet + 固定费按天 − heffingskorting 按天,取 active 版本值)。
2. ` main.py`: 1 分钟 tick job( ` max_instances=1, coalesce=True`),有 active 合同 + DSMR 数据时算;吞异常不崩。
- **Out of scope**: 不发 MQTT( T08);不加 HTTP( T09,备 service);不改采集/价格抓取。
- **Acceptance criteria**:
- [ ] 单测:构造 ` dsmr_reading`+合同版本(manual 双费率 / tibber + ` tibber_price`),` compute_period` 算出正确 cost/revenue/net,快照价 + ` contract_version_id` 落库;upsert 幂等。
- [ ] 单测:跨价格版本时按 t0 选对版本;缺价跳过、缺读数标 ` degraded`。
- [ ] 单测:` summarize` = Σnet + 固定费(月→天×天数)− heffingskorting(年→天×天数)。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 ` effective_from≤t0<effective_to`; job 不崩。
### M6-T08 — energy_cost expose provider + state 发布
- **Status**: ` todo` · **Depends**: M6-T07
- **Context**: 复用 M5 expose/Discovery,发当前买/卖价 + 累计成本/收入。
- **Files**: ` modify app/integrations/expose.py`( ` _energy_cost_provider` + 注册);` modify app/services/energy_cost.py` 或 ` app/main.py`(算完顺带 ` publish_states`);` create tests/test_energy_expose.py`
- **Steps**: provider 产出 §3.7 实体(` buy_price_now`/` sell_price_now`/` import_cost_total`/` export_revenue_total`),累计项 ` total_increasing`+` monetary`+currency; ` value_getter` 从 ` energy_cost_period` 取;计费 job 后 ` publish_states`。
- **Out of scope**: 不改 Discovery 机制本身;不改采集/计费。
- **Acceptance criteria**:
- [ ] 单测:` build_catalog` 含 energy_cost 实体;累计项 ` total_increasing`;勾选默认未勾。
- [ ] 单测:` value_getter` 取到正确当前价/累计值;MQTT 未启用整链 no-op。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: ` key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 ` total_increasing` 语义正确;不阻塞计费 job。
### M6-T09 — Energy 数据 API
- **Status**: ` todo` · **Depends**: M6-T05, M6-T07
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。
- **Files**: ` create app/api/routes/api/energy.py`、` app/schemas/energy.py`; ` modify app/main.py`(注册);` create tests/test_api_energy.py`
- **Steps**: ` GET /prices`、` GET /costs`、` GET /costs/summary`、` GET /dsmr/latest`、` POST /costs/recompute`(调 T07)、` POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。
- **Out of scope**: 不写采集/计费/发布逻辑(复用 service)。
- **Acceptance criteria**:
- [ ] 各端点行为正确:窗口+limit 上限生效;recompute 幂等;tibber/test 三态;401/403 正确;schema 入库(` git diff --exit-code openapi/` 无差异)。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
- **Status**: ` todo` · **Depends**: M6-T04, M6-T09
- **Files**: ` modify frontend/src/pages/EnergyPage.tsx`; ` create frontend/src/energy/ContractManager.tsx`、` ContractForm.tsx`、` TibberPrices.tsx`、` CostView.tsx`、扩 ` hooks.ts`; ` modify` Config 页 Tibber 测试入口;` create` 对应 ` *.test.tsx`
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 ` /profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
- **Out of scope**: 不做服务端降采样;不改后端。
- **Acceptance criteria**:
- [ ] 合同 CRUD/激活/版本可用,表单按 profile 渲染;价格/费用/汇总渲染正确;区间只取窗口;币种/单位来自 API。
- [ ] Tibber 测试三态可用。
- [ ] 前端闸门全绿(` lint`/` typecheck`/` test`/` build`)。
- **Reviewer checklist**: 全走类型化 client;图表组件隔离复用;表单按结构渲染、不 hardcode 字段;窗口/上限;空/错/加载/缺 key 容错。
### M6-T11 — 文档 + OpenAPI + roadmap 收尾
- **Status**: ` todo` · **Depends**: 全部
- **Files**: ` modify README.md`、` docs/roadmap.md`( M6 行 + 毕业)、` docs/architecture-overview.md`、` docs/design/README.md`(列入 m6);` run python scripts/export_openapi.py` 并提交 ` openapi/`
- **Acceptance criteria**:
- [ ] 文档反映新链路;` git diff --exit-code openapi/` 无未提交差异;校验闸门全绿。
- **Reviewer checklist**: 无残留过时描述;OpenAPI 入库;§13 已敲定项同步回文档。
---
## 8. 前端校验闸门
在 ` frontend/` 下:` npm ci && npm run lint && npm run typecheck && npm run test && npm run build`(留意 chunk 体积告警)。后端同任务改路由/schema 仍需根目录 ` export_openapi.py` 并提交 ` openapi/`。**不新增前端依赖**( Recharts 已在)。
## 9. 构建上下文完整性(M1 教训)
- **纯新增、不删/移文件**, **不新增依赖**( httpx/paho/pyyaml 已在)。新增源文件都在 ` app/`、` scripts/`、` frontend/` 既有 ` COPY` 范围内,无需改 ` Dockerfile`; **` app/integrations/pricing/profiles/*.yaml` 是非 .py 资源**——确认随 ` COPY app` 进镜像、运行期按 ` __file__`/` importlib.resources` 定位(别用 CWD 相对路径)。` tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
- 改 ` app/main.py` lifespan 注册新 job/订阅时**不得破坏**既有 public-ip / modbus-poll / discovery job 与 MQTT 连接。
- 发版前置走查(CLAUDE.md):真起 app → 订到 ` dsmr/json`、(合同生效后)拉一次 Tibber 价、看费用落库、前端合同/价格/费用视图人工瞄一眼,再打 tag。
## 10. 后续杠杆(本里程碑不做,文档留痕)
- **回送阶梯罚金(terugleverkosten)**:按自然年累计、阶梯式;本期不做。将来做就把阶梯表存进合同结构,在**年度汇总**层按 YTD 累计回送量套阶梯(不进每周期引擎)。
- **燃气 / 区域供暖计费**:数据已在 blob 里(gas ` extra_device_*`);将来在合同结构加"商品价"+ ` energy_cost_period` 加 ` commodity` 维度即可,不改主链。
- **能源税分档**:年用电跨 10000 kWh 档时税率变;现按单档配置。
- **降采样 / 保留**: ` dsmr_reading` 10s 长期行数大,加定期降采样/保留窗口任务(SQL 端 ` AVG(json_extract(...))`)。
- **价格 level 配色 / 便宜时段提示**:用 Tibber ` level` 驱动前端配色与用电建议。
- **HA 让 HA 算 vs 后端算**:当前后端算累计;也可只发买/卖价 €/kWh 让 HA Energy 自乘(实体都已具备)。
## 11. 人工验收 walkthrough(实现完成后)
> 受控手工验证(依赖家里 DSMR Reader + broker; Tibber 部分待合同生效),不进自动化。
1. **合同(先用 manual,立即可用)**:Energy 页新建一个 ` manual` 合同,按表单填双费率/能源税/固定费/抵扣,激活。
2. **DSMR 落库**:开 ` dsmr_ingest_enabled`、确认 topic; MQTT Explorer 看 ` dsmr/json` 有消息;等若干 10 秒 → ` GET /api/energy/dsmr/latest` 有最新整帧;` dsmr_reading` 约每 10 秒一行(整帧 blob)。
3. **费用计算**:跨过一个整刻钟边界 → ` GET /api/energy/costs` 出现该周期 import/export/cost/revenue/net( manual 双费率分档对得上手算);` GET /costs/summary` 含固定费/抵扣;改价加新版本后旧周期不变、` POST /recompute` 才覆盖。
4. **Tibber(合同生效后)**:建 ` tibber` 合同、Config 填 token、点「Tibber 测试」三态成功;激活后抓价 → ` GET /prices` 有 15min 价、费用切到动态。
5. **反哺 HA**: Expose 勾选 energy_cost 实体、开 Discovery、重发 → HA 出现当前买/卖价 + 累计成本/收入;HA Energy 挂累计实体。
## 12. 里程碑完成定义(DoD)
- 后端能:订 ` dsmr/json` 按 10s 整帧落库;按 active 合同(manual 双费率 / tibber 动态)每 15min 用寄存器差 × 价算计量电费(不可变、快照);日/月/年汇总加固定费 − 抵扣;不做净计量;改价走版本、历史不回改。
- 复用 M5 Discovery 把当前买/卖价 + 累计成本/收入(` total_increasing`)发成 HA 实体。
- 前端 Energy 视图能管理合同(表单按 profile 渲染、激活、版本)、看价格曲线与费用走势/明细/汇总、测 Tibber。
- 后端 ` pytest`/` ruff`/` export_openapi` + 前端 ` lint/typecheck/test/build` 全绿且 ` openapi/` 入库。
- README / architecture / roadmap / design 索引反映 M6。
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
1. **买价**:✅ tibber = API ` total`( demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
2. **卖价残差(tibber) **: ` sell = total − energy_tax − sell_adjust`, ` sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。
3. **双费率寄存器映射**: ` _1`=dal/低、` _2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
4. **能源税年值**: manual/tibber 的 ` energy_tax` 默认 ~0.1108( 2026 第一档含 VAT),按当年实际值核。
5. **Tibber 15min + 币种** :✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
6. **manual 现行数值** (你的固定合同):normal 0.133 / dal 0.127 / 回送两档(现同价)/ 管理费 ≈€0.329/天(≈€9.87/月)/ 电网费 + heffingskorting 待填。
</content>