Files
home-automation/docs/design/m6-tibber-dynamic-energy.md
T

484 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算(含 Tibber 动态 / 固定合同两种 profile)
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 **M5MQTT + 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. 现状(实现者可据此工作,不必通读全仓库)
**单库 + AlembicM5 完成态)**
- `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`(默认未勾不暴露)。
**后台调度(APSchedulerUTC**
- `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 • nameUI 自由填) │
│ • 有哪些字段、单位、计费语义 • 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`(默认 falseopt-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 CET12: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 blob10s 降采样 |
| **`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 | 当前周期/当前档买价(€/kWhmonetary | HA 当前单价 |
| `sell_price_now` | sensor | 当前卖价(€/kWh | |
| `import_cost_total` | sensor | 累计 import_cost`total_increasing`monetarycurrency | 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` 默认 falseTibber 抓取/计费由 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 ingestblob/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 DAPI + 前端)
M6-T09 Energy 数据 APIprices/costs/summary/dsmr-latest/recompute (Depends T05,T07)
└─► M6-T10 前端:合同管理 + 价格/费用视图 + Tibber 测试 (Depends T04,T09)
收尾
M6-T11 文档 + OpenAPI + roadmapDepends 全部)
```
`T01`、`T02`、`T03` 无前置可先开。
---
## 7. 原子任务(任务卡)
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库);前端闸门见 §8。**不新增依赖**。纯新增、不删/移文件;改 `app/main.py` lifespan 时不得破坏既有 job/连接。
### M6-T01 — 5 张表与模型 `[schema]`
- **Status**: `todo` · **Depends**: none
- **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 凭据)
- **Status**: `todo` · **Depends**: none
- **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` 为 secretbool=checkbox、数值=number`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。
- **Out of scope**: 不建客户端/采集;不动既有字段;不放任何价格常数。
- **Acceptance criteria**:
- [ ] 新项在 `GET /api/config` 分 sectiontoken 为 secret(回空/留空保留);非法值 422 不写库。
- [ ] 校验闸门全绿(OpenAPI 变化则重导出)。
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;`_settings_payload` 无遗漏;默认 off。
### M6-T03 — pricing profile 框架 + strategy 注册表
- **Status**: `todo` · **Depends**: none
- **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=totalenergy_taxsell_adjust`,进出口求和。
- **Out of scope**: 不连真实 APItibber 策略只读 `tibber_price` 表,由 T05 填);不写采集/调度/接口。
- **Acceptance criteria**:
- [ ] 单测:`load_profile("manual"/"tibber")` 校验通过;缺字段/类型错抛可识别错;`validate_values` 正确判合规。
- [ ] 单测:manual 策略双费率出价正确(`import = Δd1×buy_dal + Δd2×buy_normal` 等);tibber 策略 `buy=total`、`sell=totalenergy_taxsell_adjust`,负 total 出口得负值。
- [ ] Decimal 计算无 float 误差。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API
- **Status**: `todo` · **Depends**: M6-T01, M6-T03
- **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 403schema 经 OpenAPI 固化入库。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。
### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
- **Status**: `todo` · **Depends**: M6-T01, M6-T02, M6-T03
- **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/r2Decimal);`compute_period(session, t0)`(取 active 版本 + strategyT03/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**: 不发 MQTTT08);不加 HTTPT09,备 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 + brokerTibber 部分待合同生效),不进自动化。
1. **合同(先用 manual,立即可用)**:Energy 页新建一个 `manual` 合同,按表单填双费率/能源税/固定费/抵扣,激活。
2. **DSMR 落库**:开 `dsmr_ingest_enabled`、确认 topicMQTT Explorer 看 `dsmr/json` 有消息;等若干 10 秒 → `GET /api/energy/dsmr/latest` 有最新整帧;`dsmr_reading` 约每 10 秒一行(整帧 blob)。
3. **费用计算**:跨过一个整刻钟边界 → `GET /api/energy/costs` 出现该周期 import/export/cost/revenue/netmanual 双费率分档对得上手算);`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.11082026 第一档含 VAT),按当年实际值核。
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
6. **manual 现行数值**(你的固定合同):normal 0.133 / dal 0.127 / 回送两档(现同价)/ 管理费 ≈€0.329/天(≈€9.87/月)/ 电网费 + heffingskorting 待填。
</content>