- app/integrations/tibber/client.py: fetch_price_range/fetch_current_price (priceInfoRange QUARTER_HOURLY, startsAt->UTC, no fixed node count assumption), TibberError/TibberAuthError; token never logged or put in exception text. - app/services/tibber_prices.py: refresh_prices upserts by starts_at (idempotent), no-op unless an active kind=tibber contract + token exist. - main.py: hourly tibber-refresh job (existing jobs/MQTT untouched). Tests added.
44 KiB
M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算(含 Tibber 动态 / 固定合同两种 profile)
阅读前提:先读
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 仪表盘:
- 通用电价层(两层模型):仓库内只读 YAML profile 定义合同结构(
manual固定/可变费率、tibber动态电价各一个结构);DB 的EnergyContract(+ 版本)存可改的数值(UI 填);price strategy 按kind出价。一次激活一个合同。 - DSMR 实时数据接入:订阅 DSMR Reader 的
dsmr/json(每秒一条完整 telegram),整帧存为 JSON blob、按 10 秒降采样落库(电、气、各相全存,未来加供暖不改表)。 - 实时买卖电费计算(两层):每 15 分钟用电表累计寄存器差值 × 当时合同的买/卖价算计量电费(不可变、快照价);日/月/年汇总再加固定费、减能源税抵扣。不做净计量(saldering 2027 取消)。
- 反哺 Home Assistant:复用 M5 expose/Discovery,把当前买/卖价、累计买电支出、累计卖电收入发成 HA 实体(累计用
total_increasing)。 - 前端: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真实索引列 +payloadJSON)——dsmr_reading直接沿用。- M5 两层模型(
app/integrations/modbus/profiles/*.yaml定结构 +modbus_device存部署值 + pydantic 启动期校验 +decode)是本里程碑电价层的范本。
配置系统(扁平 KV,自动渲染)
app/config.pySettings(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.pyMqttManager:is_configured/is_connected/connect/disconnect/reconnect/publish,paholoop_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.pyExposedEntityToggle(默认未勾不暴露)。
后台调度(APScheduler,UTC)
app/main.pylifespan: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/* + 前端
三条核心原则:
- 算钱用寄存器差值,不用功率积分:每 15 分钟能量 = 累计寄存器@末 − @初,电表真值、与采样频率无关。瞬时功率只用于显示(且 HA 里已有,后端不专门用它算钱)。
- 结构固定(YAML)、数值可改(UI/DB)、历史不可变(快照 + 版本):profile YAML 定合同长什么样;UI 填数值、改价存新版本;算过的周期快照当时的价,永不被回改。
- 两层费用:每 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 填)
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 结构
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_readingpayload 哲学):不做字段 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 POSThttps://api.tibber.com/v1-beta/gql,Bearer <token>。 - 15 分钟查询(introspection + demo 实证):
{ 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_atupserttibber_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),仿 M5mqtt/test。
3.4 计费引擎(两层)
第一层 — 每 15 分钟计量电费(energy_cost_period,不可变、快照价)
- 触发:APScheduler 1 分钟 tick,找"已闭合未算"的 15 分钟周期算(也支持按区间手动重算)。
- 单周期
[t0,t1)(t1已过):- 取各寄存器在
t0/t1的值(recorded_at ≤ 边界的最后一行,Decimal),算 per-register 差:Δd1,Δd2,Δr1,Δr2。 - 取 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。
net_cost = import_cost − export_revenue;upsertenergy_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 不可变 / 审计(贯穿)
- 算账快照:
energy_cost_period存死当时实际用的价 +contract_version_id;改合同 / 改价不回改已算周期——"以前的价就是以前的价"。重算是显式 opt-in(POST /recompute),不自动覆盖历史。 - 合同版本只增不改:改价 = 新
energy_contract_version(带生效日期),旧版本保留,全程审计链。 - Tibber 价不可变:过去某 15 分钟的价不会变。
- 三者叠加 → 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. 已锁定决策(讨论后拍板)
- 两层电价模型:profile YAML 定结构(仓库、固定、UI 不可编辑)+
EnergyContract(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。 - kind 不叫 "fixed":
manual(人工填、可双费率、可带时段)/tibber(API 动态);合同nameUI 自由填。 - 买价:tibber = API
total(全包,已证 total=energy+tax);manual =energy_buy_档 + energy_tax。卖价:tibber =total − energy_tax − sell_adjust;manual =sell_档(回送价,无能源税)。均含 VAT。 - 双费率:manual 用
delivered_1/2、returned_1/2分 dal/normal 计价(_1=dal/低、_2=normal/高);tibber 求和、15min 价不分档。 - 两层费用:每 15min
energy_cost_period只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。 - 回送阶梯罚金(terugleverkosten)不做:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
- DSMR 整帧存 JSON blob(不做字段 allowlist),10s 降采样;数值转 Decimal、null 容错、
id幂等。扩MqttManager订阅端。 - 5 张表:
dsmr_reading、energy_contract、energy_contract_version、tibber_price、energy_cost_period。单电表不设 device 表。 - 不可变 / 审计:算账快照价 + 合同版本只增不改 + tibber 价天然不可变;重算显式 opt-in。
- Tibber 用 httpx +
priceInfoRange(QUARTER_HOURLY),仅 active=tibber 时抓;token 走 secret config。 - 复用 M5 expose/Discovery:加
_energy_cost_provider;累计成本/收入用total_increasing喂 HA Energy。 - 前端并入 Energy 视图:合同管理表单按 profile 结构渲染;Recharts 画价/费。
- opt-in 开关:
dsmr_ingest_enabled默认 false;Tibber 抓取/计费由 active 合同存在性驱动。 - 周期边界用 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.pylifespan 时不得破坏既有 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:
- 按 §3.5 定义模型(
Mapped[...]):DsmrReading(recorded_atidx、source_idunique nullable、payloadJSON);EnergyContract(name/kind/active/currency/时间戳);EnergyContractVersion(contract_idFK RESTRICT、effective_from/effective_tonullable、valuesJSON、created_at);TibberPrice(starts_atunique、resolution、energy/tax/totalfloat、levelnullable、currency、fetched_at);EnergyCostPeriod(period_startunique、d1/d2/r1/r2_kwhfloat、import_cost/export_revenue/net_costfloat、currency、pricingJSON、contract_version_idFK RESTRICT nullable、degradedbool、computed_at)。 - 新 revision:
down_revision="20260622_10_exposed_entities";upgrade()建 5 表 + 索引/唯一/FK;downgrade()反向 drop。更新APP_BASELINE_REVISION。
- 按 §3.5 定义模型(
- Out of scope: 不写客户端/采集/计费/接口;不动其它模型。
- Acceptance criteria:
- 全新临时库 upgrade 到 head 含 5 表及约束;
downgrade -1干净回滚;链上单 head。 - FK 为 RESTRICT;唯一/索引正确;
payload/values/pricing为 JSON 列。 APP_BASELINE_REVISION== 新 head。- 校验闸门全绿。
- 全新临时库 upgrade 到 head 含 5 表及约束;
- 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:
profiles.py:pydantic 模型描述 §3.1 结构;load_profile(kind)(读 YAML,校验失败抛错);validate_values(kind, values)(合同数值是否符合结构)。profiles/*.yaml:按 §3.1 写 manual / tibber 结构。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:
contracts.pyservice:建合同(含首版本,validate_values校验)、加版本(带effective_from,自动闭合上一版本effective_to)、激活(把其它 active 置 false)、active_contract_version_at(session, ts)(取 active 合同在 ts 生效的版本)。- 路由:
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 固化入库。
- 校验闸门全绿。
- CRUD + 版本 + 激活行为正确:新建按 profile 校验(不合规 422);加版本自动闭合上一段;激活互斥(同时只一个 active);
- 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:
client.py:fetch_price_range(token, home_id|None) -> list[PricePoint](§3.3 query,httpx 超时/认证失败抛错,不假设固定节点数);fetch_current_price(...)(供 test)。tibber_prices.py:refresh_prices(session, settings)(拉今天+明天 upsert,幂等);仅当 active 合同 kind=tibber 且 token 存在时执行。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_atupsert,不重复插);非 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:
mqtt.py:subscribe(topic, handler)(on_connect里对已注册 topic subscribe,on_message按 topic 分发,handler 异常吞掉不崩连接);保持既有 publish 不回归。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,存在则跳过)。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:
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 版本值)。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/无差异)。 - 校验闸门全绿。
- 各端点行为正确:窗口+limit 上限生效;recompute 幂等;tibber/test 三态;401/403 正确;schema 入库(
- 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;modifyConfig 页 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.pylifespan 注册新 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_reading10s 长期行数大,加定期降采样/保留窗口任务(SQL 端AVG(json_extract(...)))。 - 价格 level 配色 / 便宜时段提示:用 Tibber
level驱动前端配色与用电建议。 - HA 让 HA 算 vs 后端算:当前后端算累计;也可只发买/卖价 €/kWh 让 HA Energy 自乘(实体都已具备)。
11. 人工验收 walkthrough(实现完成后)
受控手工验证(依赖家里 DSMR Reader + broker;Tibber 部分待合同生效),不进自动化。
- 合同(先用 manual,立即可用):Energy 页新建一个
manual合同,按表单填双费率/能源税/固定费/抵扣,激活。 - DSMR 落库:开
dsmr_ingest_enabled、确认 topic;MQTT Explorer 看dsmr/json有消息;等若干 10 秒 →GET /api/energy/dsmr/latest有最新整帧;dsmr_reading约每 10 秒一行(整帧 blob)。 - 费用计算:跨过一个整刻钟边界 →
GET /api/energy/costs出现该周期 import/export/cost/revenue/net(manual 双费率分档对得上手算);GET /costs/summary含固定费/抵扣;改价加新版本后旧周期不变、POST /recompute才覆盖。 - Tibber(合同生效后):建
tibber合同、Config 填 token、点「Tibber 测试」三态成功;激活后抓价 →GET /prices有 15min 价、费用切到动态。 - 反哺 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 + 账单后钉死,均已落成配置/默认值,不阻塞实现)
- 买价:✅ tibber = API
total(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。 - 卖价残差(tibber):
sell = total − energy_tax − sell_adjust,sell_adjust默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。 - 双费率寄存器映射:
_1=dal/低、_2=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。 - 能源税年值:manual/tibber 的
energy_tax默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。 - Tibber 15min + 币种:✅ 查询/分辨率已 demo 证实;仍需合同生效后用真实 token 确认 NL 返回真 15 分钟价 + 币种 EUR。
- manual 现行数值(你的固定合同):normal 0.133 / dal 0.127 / 回送两档(现同价)/ 管理费 ≈€0.329/天(≈€9.87/月)/ 电网费 + heffingskorting 待填。