# 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_.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_fee − sell_adjust energy_tax: { unit: EUR/kWh } sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted 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 `。 - **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_fee − sell_adjust`(`sell_fee`=verkoopvergoeding,默认 0.0248,见下修正说明);`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;含 inkoopvergoeding);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding 默认 0.0248);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]` - **Status**: `done` · **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**: `done` · **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` 为 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 注册表 - **Status**: `done` · **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=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) - **Status**: `done` · **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 403;schema 经 OpenAPI 固化入库。 - [ ] 校验闸门全绿。 - **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。 ### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test - **Status**: `done` · **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**: `done` · **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**: `done` · **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 受控手工验证(依赖家里 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(买卖费相等抵消)~~ → **已修正(2026-07,见 references §3.1)**:`total` 含 inkoopvergoeding,净计量回送 = `total − verkoopvergoeding`,两费**不抵消**。公式改为 `sell = total − energy_tax − sell_fee − sell_adjust`,新增 `sell_fee`(默认 0.0248,始终扣除);`sell_adjust` 净计量期设 `−energy_tax`。真实账单确认后若有残差再调 `sell_fee`。 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 待填。