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