Files
home-automation/docs/design/m6-tibber-dynamic-energy.md
T
tliu93 4284bd40a7 M6-T04: add EnergyContract CRUD + versions + profile validation API
- app/services/contracts.py: create_contract, add_version (append-only,
  auto-closes prior version), activate_contract (mutex), and
  active_contract_version_at (half-open [from, to)) for the billing engine.
- app/schemas/energy_contract.py + routes/api/energy_contracts.py: 6 endpoints
  (GET/POST contracts, GET/PATCH contracts/{id}, POST .../versions, GET profiles).
  Values validated against pricing profiles (422 on mismatch); no DELETE.
- main.py registers router; OpenAPI re-exported; tests for CRUD/version/activate.
2026-06-23 21:19:25 +02:00

44 KiB
Raw Blame History

M6 — 通用电价层 + DSMR 实时数据 → 实时买卖电费计算(含 Tibber 动态 / 固定合同两种 profile)

阅读前提:先读 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 strategykind 出价。一次激活一个合同。
  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.pyclass Base(DeclarativeBase)cached engine(已开 journal_mode=WAL + foreign_keys=ON)。
  • 单 Alembic 链 alembic_app/当前 head = 20260622_10_exposed_entitiesscripts/app_db_adopt.pyAPP_BASELINE_REVISION 同。迁移命名 YYYYMMDD_NN_<desc>.py,串 down_revisionalembic_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.pyCONFIG_FIELDS 注册表 + build_runtime_settings(session, bootstrap)DB override 合并,消费方都用它+ _settings_payload(新字段补一行)。前端 ConfigPage.tsx 按 section/input_type/secret 通用渲染,bool 渲染成开关。
  • ⚠️ 扁平 KV 装不下"合同清单 + 逐字段数值 + 版本"——这些走专用表 + 专用 API + 自定义 UI(仿 M5 modbus_device)。

MQTTM5,关键:当前只发不收)

  • app/integrations/mqtt.py MqttManageris_configured/is_connected/connect/disconnect/reconnect/publishpaho loop_start() 后台线程,VERSION2 回调。subscribe/on_message——M6-T06 在此扩订阅端。
  • app/integrations/expose.pyExposableEntitydataclassvalue_getter: Callable[[Session],Any])、EntityProvider = Protocol(session)->list[ExposableEntity]register_provider + build_catalog。已有 _modbus_providerM6 加 _energy_cost_provider 复用整条 Discovery 链
  • app/services/ha_discovery.pybuild_discovery_payload/publish_discovery/publish_states/publish_device_stateapp/models/expose.py ExposedEntityToggle(默认未勾不暴露)。

后台调度(APSchedulerUTC

  • app/main.py lifespanBackgroundScheduler(timezone="UTC")add_job(..., max_instances=1, coalesce=True);同步 wrapper 自管 session、吞异常不崩 job。已有 public-ip / modbus-poll / discovery-state 等 job——新增 job/订阅不得破坏它们

依赖httpxTibber GraphQL)、paho-mqttpyyamlapscheduler 均已在 requirements.in/.txtM6 不新增 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.yamltibber.yaml,pydantic 启动期校验。声明该 kind 有哪些字段、单位、计费语义、哪些分档——不放具体数值
  • EnergyContract 行(部署,UI 填值)name(自由填,不 hardcode "fixed")、kindUI 选)、activecurrency
  • EnergyContractVersion(版本/时段,可审计)effective_from/effective_to(null=开口) + values(JSON,符合 profile 结构)。改价 = 加一个新版本行,旧版本保留;"2026 上半年一个价、下半年另一个价" = 同合同两个版本。引擎算某周期时挑覆盖该时刻的版本
  • price strategy(代码,按 kindmanual 用版本里的双费率常数出价;tibbertibber_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_reading payload 哲学):不做字段 allowlist,整帧存下来(电、气 extra_device_*、各相全要)。理由:现有燃气、未来供暖、三相——blob 全装,不改表;读取时按 key 取。
  • 实测 JSON 的坑(已确认):数值是 JSON 字符串"20915.154")→ 计费读取时转 Decimal;缺测相位是 null → 视为缺测;idtelegram 自增)做幂等去重。
  • 降采样:仅当 timestamp 秒数落在 dsmr_sample_interval_s(默认 10)整数倍时落盘,其余丢弃(量从 60 行/分降到 ~6 行/分;秒=00 在网格上,每个 15 分钟边界都抓得到)。
  • 订阅:扩 MqttManager 订阅端(on_connect 里 subscribe、topic→handleron_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.pyhttpx POST https://api.tibber.com/v1-beta/gqlBearer <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)、taxstartsAt(含时区偏移)、currencylevel
    • 枚举 PriceInfoRangeResolutionDAILY/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
      • manualimport_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_pricestarts_at ≤ t0 最近一条);buy = totalsell = total energy_tax sell_adjustimport_cost = (Δd1+Δd2)×buyexport_revenue = (Δr1+Δr2)×sell
    3. net_cost = import_cost export_revenueupsert 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 namekind(manual/tibber)、activecurrency、时间戳 合同头;一次一个 active
energy_contract_version contract_id(FK)、effective_fromeffective_to(null)、values(JSON)、created_at 版本/时段;改价加新版本、旧版本保留(审计)
tibber_price starts_at(unique)、resolutionenergytaxtotallevelcurrencyfetched_at 15min 价缓存,不可变;仅 tibber kind
energy_cost_period period_start(unique)、d1/d2/r1/r2_kwh(周期差)、import_costexport_revenuenet_costcurrencypricing(JSON 快照)、contract_version_id(FK)、degradedcomputed_at 每15min 计量电费,不可变(快照价+来源版本)
  • energy_cost_period 存 per-register 差 + 快照价 → 完全可审计、source-agnostic、可重算覆盖。
  • FKenergy_contract_version.contract_id → energy_contract.idON DELETE RESTRICT);energy_cost_period.contract_version_id → energy_contract_version.idRESTRICT,保审计链)。

3.6 不可变 / 审计(贯穿)

  1. 算账快照energy_cost_period 存死当时实际用的价 + contract_version_id;改合同 / 改价不回改已算周期——"以前的价就是以前的价"。重算是显式 opt-inPOST /recompute),不自动覆盖历史。
  2. 合同版本只增不改:改价 = 新 energy_contract_version(带生效日期),旧版本保留,全程审计链。
  3. Tibber 价不可变:过去某 15 分钟的价不会变。
  4. 三者叠加 → manual / tibber 都满足审计与不可变。

3.7 MQTT / HA expose(复用 M5

app/integrations/expose.py::_energy_cost_providerregister_provider),归一个 HA deviceidentifiers="energy-cost"):

key component 取值 用途
buy_price_now sensor 当前周期/当前档买价(€/kWh,monetary HA 当前单价
sell_price_now sensor 当前卖价(€/kWh
import_cost_total sensor 累计 import_costtotal_increasingmonetarycurrency HA Energy "总成本实体"
export_revenue_total sensor 累计 export_revenuetotal_increasing HA Energy 回送补偿
  • value_getter(session)energy_cost_period(累计/当前)取值。沿用 M5 勾选(默认未勾)、publish_discoverypublish_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(人工填、可双费率、可带时段)/ tibberAPI 动态);合同 name UI 自由填。
  3. 买价tibber = API total(全包,已证 total=energy+tax);manual = energy_buy_档 + energy_tax卖价tibber = total energy_tax sell_adjustmanual = sell_档(回送价,无能源税)。均含 VAT。
  4. 双费率manual 用 delivered_1/2returned_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_readingenergy_contractenergy_contract_versiontibber_priceenergy_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 全部)

T01T02T03 无前置可先开。


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.pyDsmrReading/EnergyContract/EnergyContractVersion/TibberPrice/EnergyCostPeriod);create alembic_app/versions/20260623_11_energy_tables.pymodify alembic_app/env.pyscripts/app_db_adopt.pyAPP_BASELINE_REVISION→新 head);create tests/test_energy_models.py
  • Steps:
    1. 按 §3.5 定义模型(Mapped[...]):DsmrReadingrecorded_at idx、source_id unique nullable、payload JSON);EnergyContractname/kind/active/currency/时间戳);EnergyContractVersioncontract_id FK RESTRICT、effective_from/effective_to nullable、values JSON、created_at);TibberPricestarts_at unique、resolutionenergy/tax/total float、level nullable、currencyfetched_at);EnergyCostPeriodperiod_start unique、d1/d2/r1/r2_kwh float、import_cost/export_revenue/net_cost float、currencypricing JSON、contract_version_id FK RESTRICT nullable、degraded bool、computed_at)。
    2. 新 revisiondown_revision="20260622_10_exposed_entities"upgrade() 建 5 表 + 索引/唯一/FKdowngrade() 反向 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.pyapp/services/config_page.pyCONFIG_FIELDS + _settings_payload);modify .env.exampletests/test_api_config.py
  • Steps: Settingsdsmr_ingest_enabled: bool=Falsedsmr_mqtt_topic: str="dsmr/json"dsmr_sample_interval_s: int=10tibber_api_token: str=""tibber_home_id: str=""CONFIG_FIELDS 归「DSMR」「Tibber」sectiontibber_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: done · Depends: none
  • Context: 仓库内 manual.yaml/tibber.yaml 定结构(pydantic 校验),加 price strategy 注册表(manual/tibber 出价)。纯模块,单测。
  • Files: create app/integrations/pricing/__init__.pypricing/profiles.pypydantic 模型 + load_profile/list_profiles/validate_values)、pricing/profiles/manual.yamlpricing/profiles/tibber.yamlpricing/strategies.pyregister_strategy/get_strategymanual/tibber 实现);create tests/test_pricing_profiles.pytests/test_pricing_strategies.py
  • Steps:
    1. profiles.pypydantic 模型描述 §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 + odesell_x 直接用。
      • tibber:取覆盖 t0 的 tibber_pricebuy=totalsell=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=totalsell=totalenergy_taxsell_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.pyapp/schemas/energy_contract.pyapp/services/contracts.pymodify 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}/versionsGET /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__.pytibber/client.pyapp/services/tibber_prices.pymodify app/main.py(抓价 job);create tests/test_tibber_client.pytests/test_tibber_prices.py
  • Steps:
    1. client.pyfetch_price_range(token, home_id|None) -> list[PricePoint](§3.3 query,httpx 超时/认证失败抛错,不假设固定节点数);fetch_current_price(...)(供 test)。
    2. tibber_prices.pyrefresh_prices(session, settings)(拉今天+明天 upsert,幂等);仅当 active 合同 kind=tibber 且 token 存在时执行。
    3. main.py:同步 wrapper + IntervalTriggermax_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.pymodify app/main.py(启用时注册订阅);create tests/test_mqtt_subscribe.pytests/test_dsmr_ingest.py
  • Steps:
    1. mqtt.pysubscribe(topic, handler)on_connect 里对已注册 topic subscribeon_message 按 topic 分发,handler 异常吞掉不崩连接);保持既有 publish 不回归。
    2. dsmr_ingest.pyhandle_message(payload_bytes, settings)parse JSON → 取 timestamp(UTC) → 降采样(second % dsmr_sample_interval_s != 0 丢弃)→ 整帧存 payload(值原样/Decimal 友好)→ 短 session 写 dsmr_readingsource_id=id,存在则跳过)。
    3. main.pydsmr_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.pymodify app/main.py(周期 job);create tests/test_energy_cost.py
  • Steps:
    1. energy_cost.pyregister_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.py1 分钟 tick jobmax_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_tojob 不崩。

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.pyapp/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+currencyvalue_getterenergy_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.pyapp/schemas/energy.pymodify app/main.py(注册);create tests/test_api_energy.py
  • Steps: GET /pricesGET /costsGET /costs/summaryGET /dsmr/latestPOST /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.tsxcreate frontend/src/energy/ContractManager.tsxContractForm.tsxTibberPrices.tsxCostView.tsx、扩 hooks.tsmodify 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.mddocs/roadmap.mdM6 行 + 毕业)、docs/architecture-overview.mddocs/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 范围内,无需改 Dockerfileapp/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_periodcommodity 维度即可,不改主链。
  • 能源税分档:年用电跨 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. 反哺 HAExpose 勾选 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 totaldemo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
  2. 卖价残差(tibbersell = total energy_tax sell_adjustsell_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 待填。