4 Commits
Author SHA1 Message Date
tliu93 bfc7aa3031 chore(frontend): regenerate API schema after sell_fee docstring change
frontend / frontend (push) Successful in 9m59s
pytest / test (push) Successful in 12m27s
d07a083 changed the /api/energy/prices docstring (sell now deducts sell_fee)
and refreshed openapi/openapi.json, but frontend/src/api/schema.d.ts was not
regenerated, so CI's "check codegen is in sync" step failed. Comment-only diff.
2026-07-27 18:29:54 +02:00
tliu93 2f63b9630c fix(prices): keep hover marker on the hovered slot past midnight
The price chart keyed its X axis on formatLocalTime() "HH:mm" labels, which
repeat across a today+tomorrow range. Recharts resolves axis tooltips by value
(combineTooltipPayload -> findEntryInArray), so hovering a slot after midnight
matched today's identically labelled point: the tooltip showed today's prices
and the active dot jumped back to today's position instead of following the
cursor.

Key the axis on the ISO instant instead (buildChartRows, sorted by slot start)
and format down to HH:mm in the tick formatter; the tooltip label now carries
the date so today and tomorrow are distinguishable.

Also mark the price slot currently in effect by default: findActiveSlotIndex()
locates the slot containing now, rendered as a ReferenceDot on the buy and sell
lines plus a caption, re-evaluated every 30s.

Regression test drives a real mousemove over a sized chart in jsdom and asserts
the resolved slot and active-dot position.
2026-07-27 18:29:08 +02:00
tliu93 d07a083e03 fix(tibber): deduct verkoopvergoeding (sell_fee) from feed-in sell price
frontend / frontend (push) Failing after 5m49s
pytest / test (push) Successful in 20m30s
docker-image / build-and-push (push) Successful in 13m26s
Tibber's API `total` already includes the buy-side inkoopvergoeding
(verified from production data: total = spot×1.21 + energy_tax 0.11085 +
inkoopvergoeding 0.0248). Under net metering Tibber pays back
`total − verkoopvergoeding` per returned kWh (NL: EUR 0.28 -> 0.2552), so the
two EUR 0.0248 fees do NOT cancel — the feed-in price sits 0.0248 below buy.

Model the verkoopvergoeding as a first-class, always-subtracted contract
field `energy.sell_fee` (default 0.0248) instead of folding it into
`sell_adjust`. New sell formula:

    sell = total − energy_tax − sell_fee − sell_adjust

`sell_adjust` now carries only the net-metering energy-tax refund
(= −energy_tax). Applied in both the billing strategy and the /prices
endpoint; recorded in the pricing snapshot. Frontend renders the field
automatically (dynamic profile form). Docs (references, m6) corrected to
drop the wrong "fees cancel" premise.
2026-07-20 13:50:13 +02:00
tliu93 b65f700d56 fix(tibber): fetch forward-looking today+tomorrow via priceInfo(QUARTER_HOURLY)
pytest / test (push) Successful in 20m2s
frontend / frontend (push) Failing after 6m5s
docker-image / build-and-push (push) Successful in 13m27s
priceInfoRange is a historical cursor connection whose range ends at "now": it
never returns upcoming slots. With it, the DB only ever held prices up to the
last hourly refresh, which caused two problems:

  1. The price chart could only show history up to now, never a forward curve.
  2. Worse, per-slot billing was subtly wrong. _tibber_strategy looks up the
     price via `starts_at <= t0` (nearest slot at or before the period). Because
     a period's exact 15-min slot was not fetched until the next hourly refresh
     (~1h later), intra-hour periods were billed with the PREVIOUS quarter's
     price and then locked in by the immutability guard — never corrected.

Switch to priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }, which is
forward-looking AND quarter-hourly: today is always the full local day (96
slots) and tomorrow fills in once Tibber publishes day-ahead prices (picked up
by the next hourly refresh). Every 15-min slot's exact price is now in the DB
before the slot closes, so each period finds its own slot (accurate billing)
and the live current-price entity stays fresh.

Verified against the live Tibber API: fetch_price_range returns 192 points
(96 today + 96 tomorrow), spanning local today 00:00 → tomorrow 23:45, with
future slots present (previously 0).
2026-07-18 20:20:33 +02:00
16 changed files with 721 additions and 104 deletions
+5 -3
View File
@@ -169,7 +169,7 @@ def get_prices(
Response ``points`` carries per-slot: Response ``points`` carries per-slot:
- ``buy = total`` (Tibber all-inclusive price) - ``buy = total`` (Tibber all-inclusive price)
- ``sell = total energy_tax sell_adjust`` (from active version values) - ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)
- ``level`` (Tibber price level, may be null) - ``level`` (Tibber price level, may be null)
``tariff`` is null. ``tariff`` is null.
@@ -222,7 +222,8 @@ def get_prices(
) )
rows = list(reversed(db.execute(stmt).scalars().all())) rows = list(reversed(db.execute(stmt).scalars().all()))
# Derive sell price per-point using version values (energy_tax + sell_adjust). # Derive sell price per-point using version values
# (energy_tax + sell_fee + sell_adjust).
from decimal import Decimal from decimal import Decimal
def _d(v: Any) -> Decimal: def _d(v: Any) -> Decimal:
@@ -230,12 +231,13 @@ def get_prices(
energy = version.values.get("energy", {}) if version.values else {} energy = version.values.get("energy", {}) if version.values else {}
energy_tax = _d(energy.get("energy_tax", 0)) energy_tax = _d(energy.get("energy_tax", 0))
sell_fee = _d(energy.get("sell_fee", 0))
sell_adjust = _d(energy.get("sell_adjust", 0)) sell_adjust = _d(energy.get("sell_adjust", 0))
points = [] points = []
for row in rows: for row in rows:
total = _d(row.total) total = _d(row.total)
sell = float(total - energy_tax - sell_adjust) sell = float(total - energy_tax - sell_fee - sell_adjust)
points.append( points.append(
PricePointSchema( PricePointSchema(
starts_at=_as_utc(row.starts_at), starts_at=_as_utc(row.starts_at),
+10 -4
View File
@@ -123,6 +123,7 @@ class ManualProfile(BaseModel):
class TibberEnergySpec(BaseModel): class TibberEnergySpec(BaseModel):
source: str # must be "tibber_api" source: str # must be "tibber_api"
energy_tax: FieldSpec # subtracted from total to derive sell price energy_tax: FieldSpec # subtracted from total to derive sell price
sell_fee: FieldSpec # verkoopvergoeding (feed-in fee); always subtracted from sell; default 0.0248
sell_adjust: FieldSpec # additional sell-price adjustment; default 0 sell_adjust: FieldSpec # additional sell-price adjustment; default 0
@@ -268,10 +269,13 @@ def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dic
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]: def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
"""Return a copy of *values* with sell_adjust and management_fee defaults applied.""" """Return a copy of *values* with sell_fee, sell_adjust and management_fee defaults applied."""
filled = dict(values) filled = dict(values)
energy = dict(filled.get("energy", {})) energy = dict(filled.get("energy", {}))
# Apply default for sell_fee (default=0.0248) if absent.
if "sell_fee" not in energy and profile.energy.sell_fee.default is not None:
energy["sell_fee"] = profile.energy.sell_fee.default
# Apply default for sell_adjust (default=0) if absent. # Apply default for sell_adjust (default=0) if absent.
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None: if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
energy["sell_adjust"] = profile.energy.sell_adjust.default energy["sell_adjust"] = profile.energy.sell_adjust.default
@@ -347,6 +351,7 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
# Required energy fields. # Required energy fields.
_require_numeric("energy", "energy_tax", energy) _require_numeric("energy", "energy_tax", energy)
_require_numeric("energy", "sell_fee", energy)
_require_numeric("energy", "sell_adjust", energy) _require_numeric("energy", "sell_adjust", energy)
# Required standing fields. # Required standing fields.
_require_numeric("standing", "management_fee", standing) _require_numeric("standing", "management_fee", standing)
@@ -361,9 +366,10 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
"""Validate a contract-values dict against the named profile structure. """Validate a contract-values dict against the named profile structure.
Fields that carry a ``default`` in the profile (e.g. ``ode``, Fields that carry a ``default`` in the profile (e.g. ``ode``,
``sell_adjust``, tibber ``management_fee``) are silently filled in when ``sell_fee``, ``sell_adjust``, tibber ``management_fee``) are silently
absent from *values*. Fields with no default that are absent, or fields filled in when absent from *values*. Fields with no default that are
whose value is not a number, cause a ``ProfileValidationError``. absent, or fields whose value is not a number, cause a
``ProfileValidationError``.
Parameters Parameters
---------- ----------
@@ -2,9 +2,10 @@ kind: tibber
label: Tibber 动态电价(15 分钟) label: Tibber 动态电价(15 分钟)
energy: energy:
source: tibber_api # buy = total (from API); sell = total energy_tax sell_adjust source: tibber_api # buy = total (from API); sell = total energy_tax sell_fee sell_adjust
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT) energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread) sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding (feed-in fee, incl. VAT); always subtracted from sell
sell_adjust: { unit: EUR/kWh, default: 0 } # manual sell-price adjustment; net-metering: set = energy_tax to refund the tax
standing: # fixed charges; UI fills per month, engine prorates to days standing: # fixed charges; UI fills per month, engine prorates to days
management_fee: { unit: EUR/month, default: 5.99 } management_fee: { unit: EUR/month, default: 5.99 }
+16 -3
View File
@@ -209,12 +209,23 @@ def _tibber_strategy(
query on ``starts_at``. query on ``starts_at``.
Formula (§3.4): Formula (§3.4):
- ``buy = total`` (Tibber's all-inclusive price, already includes tax) - ``buy = total`` (Tibber's all-inclusive price; already includes energy
- ``sell = total energy_tax sell_adjust`` tax, VAT and the buy-side ``inkoopvergoeding``)
- ``sell = total energy_tax sell_fee sell_adjust``
- ``import_cost = (Δd1 + Δd2) × buy`` - ``import_cost = (Δd1 + Δd2) × buy``
- ``export_revenue = (Δr1 + Δr2) × sell`` - ``export_revenue = (Δr1 + Δr2) × sell``
- ``net_cost = import_cost export_revenue`` - ``net_cost = import_cost export_revenue``
``sell_fee`` models Tibber's per-kWh **verkoopvergoeding** (feed-in fee,
€0.0248/kWh incl. VAT since 2026-01-01). It is always deducted from the
feed-in payout: even under the net-metering (saldering) scheme, Tibber pays
``total verkoopvergoeding`` per returned kWh (Tibber NL: "€0,28 €0,0248
= €0,2552"). ``total`` already contains the equal buy-side
``inkoopvergoeding``, so the two fees do **not** cancel — the feed-in price
sits ``sell_fee`` below the buy price. ``sell_adjust`` is a separate manual
correction: under net metering it carries back the refunded energy tax
(``sell_adjust = energy_tax``), leaving ``sell = total sell_fee``.
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
API price applies to the full delivered/returned volume. API price applies to the full delivered/returned volume.
@@ -248,11 +259,12 @@ def _tibber_strategy(
energy = values.get("energy", {}) energy = values.get("energy", {})
energy_tax = _to_decimal(energy.get("energy_tax", 0)) energy_tax = _to_decimal(energy.get("energy_tax", 0))
sell_fee = _to_decimal(energy.get("sell_fee", 0))
sell_adjust = _to_decimal(energy.get("sell_adjust", 0)) sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
total = _to_decimal(price_row.total) total = _to_decimal(price_row.total)
buy = total buy = total
sell = total - energy_tax - sell_adjust sell = total - energy_tax - sell_fee - sell_adjust
total_delivered = deltas.d1 + deltas.d2 total_delivered = deltas.d1 + deltas.d2
total_returned = deltas.r1 + deltas.r2 total_returned = deltas.r1 + deltas.r2
@@ -269,6 +281,7 @@ def _tibber_strategy(
"buy": str(buy), "buy": str(buy),
"sell": str(sell), "sell": str(sell),
"energy_tax": str(energy_tax), "energy_tax": str(energy_tax),
"sell_fee": str(sell_fee),
"sell_adjust": str(sell_adjust), "sell_adjust": str(sell_adjust),
} }
+42 -22
View File
@@ -30,26 +30,39 @@ logger = logging.getLogger(__name__)
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql" _TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
_DEFAULT_TIMEOUT = 15.0 _DEFAULT_TIMEOUT = 15.0
# GraphQL query to fetch a range of 15-minute price nodes. # GraphQL query to fetch the forward-looking today + tomorrow price curve at
# ``priceInfoRange`` is a Relay-style cursor connection over the subscription's # 15-minute resolution.
# entire available price history. With no before/after cursor: #
# * ``first: N`` returns the OLDEST N nodes (anchored at the subscription # ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
# start date) — a fixed window that never advances, so it is WRONG for # * ``today`` — always the full current local day (96 quarter-hourly slots,
# "current + upcoming" prices. # 00:00 → 23:45 local), regardless of the current time.
# * ``last: N`` returns the NEWEST N nodes (ending at the latest published # * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
# slot: tomorrow 23:45 once day-ahead prices are out, else today 23:45). # day-ahead prices (around 13:0015:00 local); empty before that.
# We want the newest slots, so we use ``last``. 192 = 2 days × 24 h × 4 slots, #
# which fully covers the "today + tomorrow" window that GET /api/energy/prices # This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
# queries (the earliest of the 192 newest slots reaches back past today 00:00 # connection whose range ends at "now" (it never returns future slots), so it
# UTC in either publish state). # cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every
# 15-minute slot's price is present in the DB *before* the slot closes — which is
# what makes per-slot billing accurate (each period finds its own exact slot
# instead of falling back to a stale earlier price) and keeps the live current-
# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's
# prices are picked up within an hour of publication without a restart.
_PRICE_RANGE_QUERY = """ _PRICE_RANGE_QUERY = """
{ {
viewer { viewer {
homes { homes {
id id
currentSubscription { currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, last: 192) { priceInfo(resolution: QUARTER_HOURLY) {
nodes { today {
startsAt
total
energy
tax
currency
level
}
tomorrow {
startsAt startsAt
total total
energy energy
@@ -236,13 +249,15 @@ def fetch_price_range(
*, *,
timeout: float = _DEFAULT_TIMEOUT, timeout: float = _DEFAULT_TIMEOUT,
) -> list[PricePoint]: ) -> list[PricePoint]:
"""Fetch a range of 15-minute price nodes from the Tibber API. """Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber.
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, last: 192)`` query Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
and parses every returned node into a ``PricePoint``. ``last`` (not and parses every node from both lists (today first, then tomorrow) into a
``first``) is used so the newest slots are returned; ``first`` would anchor ``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
at the subscription start date and never advance. The number of nodes is full current local day and ``tomorrow`` is populated once Tibber publishes the
not assumed — all returned nodes are parsed regardless of count. day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange``
which only reaches "now". ``tomorrow`` may be empty (before publication); the
number of nodes is not assumed and all returned nodes are parsed.
Parameters Parameters
---------- ----------
@@ -276,10 +291,15 @@ def fetch_price_range(
home = _pick_home(homes, home_id) home = _pick_home(homes, home_id)
try: try:
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"] price_info = home["currentSubscription"]["priceInfo"]
today = price_info["today"]
tomorrow = price_info["tomorrow"]
except (KeyError, TypeError) as exc: except (KeyError, TypeError) as exc:
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc
# tomorrow is null/empty until Tibber publishes the day-ahead prices; treat
# a missing list as empty so we still return today's slots.
nodes = list(today or []) + list(tomorrow or [])
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes] return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
+5 -4
View File
@@ -118,8 +118,9 @@ credits:
kind: tibber kind: tibber
label: Tibber 动态电价(15 分钟) label: Tibber 动态电价(15 分钟)
energy: energy:
source: tibber_api # buy = total; sell = total energy_tax sell_adjust source: tibber_api # buy = total; sell = total energy_tax sell_fee sell_adjust
energy_tax: { unit: EUR/kWh } energy_tax: { unit: EUR/kWh }
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted
sell_adjust: { unit: EUR/kWh, default: 0 } sell_adjust: { unit: EUR/kWh, default: 0 }
standing: standing:
management_fee: { unit: EUR/month, default: 5.99 } management_fee: { unit: EUR/month, default: 5.99 }
@@ -164,7 +165,7 @@ credits:
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**`Δd1,Δd2,Δr1,Δr2`。 1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**`Δd1,Δd2,Δr1,Δr2`。
2. 取 active 合同**在 t0 生效的版本** + 其 strategy 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`。 - `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`。 - `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`。 3. `net_cost = import_cost export_revenue`**upsert** `energy_cost_period`**快照**当时用的价 + `contract_version_id`。
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。 - 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
@@ -240,7 +241,7 @@ credits:
1. **两层电价模型**profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。 1. **两层电价模型**profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
2. **kind 不叫 "fixed"**`manual`(人工填、可双费率、可带时段)/ `tibber`API 动态);合同 `name` UI 自由填。 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。 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.0248manual = `sell_档`(回送价,无能源税)。均含 VAT。
4. **双费率**manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。 4. **双费率**manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。 5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。 6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
@@ -475,7 +476,7 @@ Phase DAPI + 前端)
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现) ## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
1. **买价**:✅ tibber = API `total`demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。 1. **买价**:✅ tibber = API `total`demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
2. **卖价残差(tibber**`sell = total energy_tax sell_adjust``sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。 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 惯例)——接价前用真实数据确认别接反(差价小但要对)。 3. **双费率寄存器映射**`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
4. **能源税年值**manual/tibber 的 `energy_tax` 默认 ~0.11082026 第一档含 VAT),按当年实际值核。 4. **能源税年值**manual/tibber 的 `energy_tax` 默认 ~0.11082026 第一档含 VAT),按当年实际值核。
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。 5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
@@ -113,16 +113,26 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt." > "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。) > (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
**买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。 **买卖服务费金额相等(均 €0.0248/kWh,但两者对住户都是成本、不互相抵消**
- 买侧 inkoopvergoeding 已经**包含在 Tibber API 的 `total` 里**(见下 §3.1 的实证拆解),买电按 `total` 计价即已含它。
- 卖侧 verkoopvergoeding 则是从回送价里**额外扣掉**的一笔——所以回送价 = `total 0.0248`,比买价低 0.0248/kWh。
- ⚠️ **早期版本误判为"一进一出抵消 → 回送=total"**,这是错的:`total` 里那笔 inkoopvergoeding 不会退回来充抵 verkoopvergoeding。代码里用 `energy.sell_fee`(默认 0.0248)建模这笔卖侧费用。
--- ---
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027 ## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
### 3.1 回送价(净计量期内,文档原文) ### 3.1 回送价(净计量期内,文档原文 + 实证
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding." > "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。 > **Worked exampleTibber NL 原文)**"Stel dat tussen 14:00 en 14:15 de totale stroomprijs €0,28 per kWh incl. is, dan krijg je €0,28 €0,0248 verkoopvergoeding = **€0,2552** per teruggeleverde kWh terug."
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw verkoopvergoeding`,而官方例子直接写成 **`回送价 = totale stroomprijs verkoopvergoeding = total 0.0248`**。能源税**退回**(留在 total 里没动),只有 verkoopvergoeding 这 0.0248 被扣。
**✅ 实证(本项目生产库,2026-07-20 三个刻钟)**:按 21% VAT 拆 `total``total = 现货×1.21 + energiebelasting(0.11085) + inkoopvergoeding(0.0248)`,三段解出的 inkoop 都精确等于 **0.0248**。→ **我们存的 `tibber_price.total` 就是官方 "totale stroomprijs"(含 inkoopvergoeding 的买价)**,因此:
- 买价 `buy = total`(已含 inkoopvergoeding,正确)。
- 净计量回送价 `sell = total verkoopvergoeding = total 0.0248`
- ⚠️ 所以 saldering 下"回送 1 度"仍比"用 1 度"少 0.0248——**不是完全 1:1**。代码用 `sell_fee` 建模这笔扣减,`sell_adjust` 只负责在净计量期把能源税补回(`sell_adjust = energy_tax`)。
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2) ### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …" > "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
@@ -146,9 +156,11 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
> spot 取 API `energy``total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。 > spot 取 API `energy``total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
- **Tibber 动态合同**post-2027 口径) - **Tibber 动态合同**
- 买价 `buy = price.total` - 买价 `buy = price.total`(含 energy_tax + VAT + inkoopvergoeding
- 卖价 `sell = price.total energy_tax_per_kwh sell_adjust``sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里) - 卖价 `sell = price.total energy_tax sell_fee sell_adjust`
- `sell_fee`verkoopvergoeding(卖侧上网费,默认 **0.0248**,含 VAT),**始终扣除**——即使净计量期也扣(见 §3.1)。
- `sell_adjust`:手动修正项(默认 0)。**净计量期**设为 `energy_tax`(把能源税补回),得 `sell = total sell_fee`;**2027 取消净计量后**设为 0,得 `sell = total energy_tax sell_fee`(无能源税、纯市场价再扣上网费)。
- **固定合同(manual,双费率)**: - **固定合同(manual,双费率)**:
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal} - 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal}
- 卖价 `sell_档 = sell_档`(回送价,**无能源税** - 卖价 `sell_档 = sell_档`(回送价,**无能源税**
@@ -242,7 +254,7 @@ extra_device_timestamp, extra_device_delivered # 燃气表(m³,每
## 8. 待真实数据核对(合同生效后用真实 token / 账单) ## 8. 待真实数据核对(合同生效后用真实 token / 账单)
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。 1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0 2. ~~**卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉~~**已核实(2026-07**`total` 含 inkoopvergoeding0.0248),净计量回送价 = `total verkoopvergoeding(0.0248)`,两费**不抵消**;代码以 `sell_fee`(默认 0.0248)建模。仍待真实账单核对 `sell_fee` / VAT 口径的最终残差
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。 3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax` 4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。 5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
+1 -1
View File
@@ -261,7 +261,7 @@ export interface paths {
* *
* Response ``points`` carries per-slot: * Response ``points`` carries per-slot:
* - ``buy = total`` (Tibber all-inclusive price) * - ``buy = total`` (Tibber all-inclusive price)
* - ``sell = total energy_tax sell_adjust`` (from active version values) * - ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)
* - ``level`` (Tibber price level, may be null) * - ``level`` (Tibber price level, may be null)
* *
* ``tariff`` is null. * ``tariff`` is null.
+253 -3
View File
@@ -6,10 +6,14 @@
* 2. Empty state (no active contract / no kind). * 2. Empty state (no active contract / no kind).
* 3. Renders tibber chart when tibber kind data is available. * 3. Renders tibber chart when tibber kind data is available.
* 4. Shows tariff table for manual kind. * 4. Shows tariff table for manual kind.
* 5. Marks the currently active price slot (dot + caption).
* 6. Hovering past midnight resolves tomorrow's slot, not today's (regression).
* 7. buildChartRows: unique X keys across midnight.
* 8. findActiveSlotIndex: which slot is currently active.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react' import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils' import { renderWithProviders } from '../test-utils'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
// Import component // Import component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { TibberPrices } from './TibberPrices' import { TibberPrices, buildChartRows, findActiveSlotIndex } from './TibberPrices'
// ---------------------------------------------------------------------------
// Chart size harness
//
// jsdom reports every element as 0x0, so Recharts renders an empty plot and no
// pointer interaction is possible. These helpers hand the chart a fixed size:
// - the ResponsiveContainer gets 800x300 from its bounding rect + a ResizeObserver
// that reports the same size,
// - the chart wrapper reports 800x260 (the height the component asks for), which
// is what Recharts uses to translate clientX/clientY into chart coordinates,
// - everything else stays 0x0 so the legend does not eat the whole plot area.
// ---------------------------------------------------------------------------
const CHART_W = 800
const CONTAINER_H = 300
const CHART_H = 260
function fakeRect(width: number, height: number): DOMRect {
return {
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => {},
} as DOMRect
}
const originalResizeObserver = globalThis.ResizeObserver
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth',
)
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
)
function installChartSize() {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.classList.contains('recharts-responsive-container')) {
return fakeRect(CHART_W, CONTAINER_H)
}
if (this.classList.contains('recharts-wrapper')) return fakeRect(CHART_W, CHART_H)
return fakeRect(0, 0)
})
// Recharts divides rect size by offset size to undo CSS transform scaling;
// matching them keeps the scale factor at 1.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: CHART_W,
})
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
value: CHART_H,
})
globalThis.ResizeObserver = class implements ResizeObserver {
private readonly cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) {
this.cb = cb
}
observe() {
this.cb(
[{ contentRect: { width: CHART_W, height: CONTAINER_H } } as ResizeObserverEntry],
this,
)
}
unobserve() {}
disconnect() {}
}
}
function restoreChartSize() {
globalThis.ResizeObserver = originalResizeObserver
if (offsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthDescriptor)
}
if (offsetHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightDescriptor)
}
}
/** Hourly price points, one per hour starting at `startUtc`, with unique prices. */
function hourlyPoints(startUtc: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
starts_at: new Date(startUtc + i * 3600_000).toISOString(),
buy: 0.1 + i / 1000,
sell: 0.05 + i / 1000,
level: 'NORMAL',
}))
}
function tooltipText(): string {
return document.querySelector('.recharts-tooltip-wrapper')?.textContent ?? ''
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
describe('TibberPrices', () => { describe('TibberPrices', () => {
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
afterEach(() => {
vi.restoreAllMocks()
restoreChartSize()
})
it('renders loading state initially', () => { it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {})) mockGet.mockImplementation(() => new Promise(() => {}))
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900') expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900') expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
}) })
it('marks the currently active price slot with a dot and a caption', async () => {
installChartSize()
const SLOT_MS = 15 * 60 * 1000
// Start of the quarter-hour slot that contains "now".
const currentSlot = Math.floor(Date.now() / SLOT_MS) * SLOT_MS
mockGet.mockResolvedValue({
data: {
kind: 'tibber',
currency: 'EUR',
points: [
{ starts_at: new Date(currentSlot - SLOT_MS).toISOString(), buy: 0.11, sell: 0.05 },
{ starts_at: new Date(currentSlot).toISOString(), buy: 0.2431, sell: 0.1102 },
{ starts_at: new Date(currentSlot + SLOT_MS).toISOString(), buy: 0.31, sell: 0.15 },
],
tariff: null,
},
})
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('tibber-current-price')).toBeInTheDocument()
})
const marker = screen.getByTestId('tibber-current-price')
expect(marker).toHaveTextContent('0.2431')
expect(marker).toHaveTextContent('0.1102')
// One dot on the buy line, one on the sell line — visible without hovering.
await waitFor(() => {
expect(document.querySelectorAll('.recharts-reference-dot')).toHaveLength(2)
})
})
it('resolves the hovered slot past midnight to tomorrow, not today', async () => {
installChartSize()
// 26 hourly points starting at 2020-01-01T00:00Z, so "00:00" and "01:00"
// each appear twice. Fixed past dates keep the "now" marker out of range.
const points = hourlyPoints(Date.UTC(2020, 0, 1), 26)
mockGet.mockResolvedValue({
data: { kind: 'tibber', currency: 'EUR', points, tariff: null },
})
renderWithProviders(<TibberPrices />)
await waitFor(() => expect(screen.getByTestId('tibber-chart')).toBeInTheDocument())
expect(screen.queryByTestId('tibber-current-price')).not.toBeInTheDocument()
const wrapper = document.querySelector('.recharts-wrapper')
expect(wrapper).not.toBeNull()
// Right edge of the plot area = the last slot (day 2, 01:00, buy 0.1250).
fireEvent.mouseMove(wrapper!, { clientX: 770, clientY: CHART_H / 2 })
await waitFor(() => expect(tooltipText()).toContain('0.1250'))
// Label carries the date, so day 2 is distinguishable from day 1.
expect(tooltipText()).toContain('1/2/2020')
expect(tooltipText()).toContain('0.0750')
// The active dots must sit on the hovered point (right half of the plot).
// The bug put them on day 1's identically-labelled slot near the left edge.
const dots = Array.from(document.querySelectorAll('.recharts-active-dot circle'))
expect(dots).toHaveLength(2)
for (const dot of dots) {
expect(Number(dot.getAttribute('cx'))).toBeGreaterThan(CHART_W / 2)
}
})
})
// ---------------------------------------------------------------------------
// buildChartRows
// ---------------------------------------------------------------------------
describe('buildChartRows', () => {
it('keeps X-axis keys unique across midnight', () => {
// Same local time-of-day on two consecutive days: as "HH:mm" labels these
// collided, which made Recharts resolve the hovered point to the first match
// (today) instead of the hovered one (tomorrow).
const rows = buildChartRows([
{ starts_at: '2026-07-26T22:00:00Z', buy: 0.1, sell: 0.05 },
{ starts_at: '2026-07-27T22:00:00Z', buy: 0.2, sell: 0.06 },
])
expect(rows).toHaveLength(2)
expect(new Set(rows.map((r) => r.ts)).size).toBe(2)
})
it('sorts rows by slot start and parses naive timestamps as UTC', () => {
const rows = buildChartRows([
{ starts_at: '2026-07-27T02:00:00', buy: 0.3, sell: 0.07 },
{ starts_at: '2026-07-27T01:00:00Z', buy: 0.2, sell: 0.06 },
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
])
expect(rows.map((r) => r.buy)).toEqual([0.1, 0.2, 0.3])
expect(rows.map((r) => r.ts)).toEqual([
'2026-07-27T00:00:00.000Z',
'2026-07-27T01:00:00.000Z',
'2026-07-27T02:00:00.000Z',
])
})
})
// ---------------------------------------------------------------------------
// findActiveSlotIndex
// ---------------------------------------------------------------------------
describe('findActiveSlotIndex', () => {
const rows = buildChartRows([
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
{ starts_at: '2026-07-27T00:15:00Z', buy: 0.2, sell: 0.06 },
{ starts_at: '2026-07-27T00:30:00Z', buy: 0.3, sell: 0.07 },
])
const at = (iso: string) => new Date(iso).getTime()
it('returns the slot containing now', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:20:00Z'))).toBe(1)
})
it('returns the slot at its exact start boundary', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:15:00Z'))).toBe(1)
})
it('returns null before the first slot', () => {
expect(findActiveSlotIndex(rows, at('2026-07-26T23:59:00Z'))).toBeNull()
})
it('stays on the last slot until its inferred end, then returns null', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:44:00Z'))).toBe(2)
expect(findActiveSlotIndex(rows, at('2026-07-27T00:45:00Z'))).toBeNull()
})
it('returns null for empty data', () => {
expect(findActiveSlotIndex([], Date.now())).toBeNull()
})
}) })
+139 -14
View File
@@ -2,13 +2,15 @@
* TibberPrices — price curve visualization. * TibberPrices — price curve visualization.
* *
* - Fetches today + tomorrow price range using useEnergyPrices. * - Fetches today + tomorrow price range using useEnergyPrices.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time. * - For tibber kind: Recharts LineChart showing buy/sell prices over time,
* with the currently active price slot marked by a dot.
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal). * - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
* - Handles: no active contract, empty data, loading, error. * - Handles: no active contract, empty data, loading, error.
* *
* Recharts imports are isolated to this file only. * Recharts imports are isolated to this file only.
*/ */
import { useEffect, useMemo, useState } from 'react'
import { import {
Stack, Stack,
Text, Text,
@@ -29,10 +31,20 @@ import {
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
Legend, Legend,
ReferenceDot,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { useEnergyPrices } from './hooks' import { useEnergyPrices } from './hooks'
import { formatLocalTime } from '../utils/datetime' import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
const BUY_COLOR = '#2196f3'
const SELL_COLOR = '#4caf50'
/** Slot length assumed for the very last point, when no next point bounds it. */
const FALLBACK_SLOT_MS = 60 * 60 * 1000
/** How often the "current price" marker re-evaluates which slot is active. */
const NOW_TICK_MS = 30 * 1000
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Time range helpers // Time range helpers
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
return d.toISOString() return d.toISOString()
} }
// ---------------------------------------------------------------------------
// Chart data helpers
// ---------------------------------------------------------------------------
export interface PricePoint {
starts_at: string
buy: number
sell: number
level?: string | null
}
export interface ChartRow {
/**
* X-axis category key — the full instant, NOT a "HH:mm" label.
*
* Must be unique per slot: Recharts resolves the hovered point by *value*
* (findEntryInArray on the axis dataKey), so a repeated key makes the tooltip
* and the active dot snap back to the first match. With "HH:mm" labels, every
* time of day appears twice in a today+tomorrow range, which pinned the dot on
* today once the cursor passed midnight. Formatting to HH:mm happens in the
* tick / tooltip formatters instead.
*/
ts: string
/** Slot start as epoch ms; NaN when starts_at is unparseable. */
tsMs: number
buy: number
sell: number
}
/** Map API price points to chart rows with unique X keys, sorted by slot start. */
export function buildChartRows(points: PricePoint[]): ChartRow[] {
return points
.map((p) => {
const d = parseBackendTimestamp(p.starts_at)
const tsMs = d.getTime()
return {
ts: Number.isFinite(tsMs) ? d.toISOString() : p.starts_at,
tsMs,
buy: p.buy,
sell: p.sell,
}
})
.sort((a, b) => {
// Unparseable timestamps sort last so the ascending scan below can stop early.
if (!Number.isFinite(a.tsMs)) return Number.isFinite(b.tsMs) ? 1 : 0
if (!Number.isFinite(b.tsMs)) return -1
return a.tsMs - b.tsMs
})
}
/**
* Index of the row whose slot contains `nowMs`, or null when now is outside the
* fetched range. A slot ends where the next one starts; the last row has no next
* slot, so it falls back to the series spacing (quarter-hourly for Tibber).
*/
export function findActiveSlotIndex(rows: ChartRow[], nowMs: number): number | null {
let idx = -1
for (let i = 0; i < rows.length; i += 1) {
if (!Number.isFinite(rows[i].tsMs) || rows[i].tsMs > nowMs) break
idx = i
}
if (idx < 0) return null
const spacing = rows.length > 1 ? rows[1].tsMs - rows[0].tsMs : NaN
const slotMs = Number.isFinite(spacing) && spacing > 0 ? spacing : FALLBACK_SLOT_MS
const slotEnd = idx + 1 < rows.length ? rows[idx + 1].tsMs : rows[idx].tsMs + slotMs
return nowMs < slotEnd ? idx : null
}
/** Ticking clock so the active-slot marker follows slot boundaries while open. */
function useNowMs(intervalMs = NOW_TICK_MS): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
return now
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tibber chart // Tibber chart
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface TibberChartProps { interface TibberChartProps {
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }> points: PricePoint[]
currency: string currency: string
} }
function TibberChart({ points, currency }: TibberChartProps) { function TibberChart({ points, currency }: TibberChartProps) {
const data = points.map((p) => ({ const data = useMemo(() => buildChartRows(points), [points])
time: formatLocalTime(p.starts_at), const nowMs = useNowMs()
buy: p.buy, const activeIndex = findActiveSlotIndex(data, nowMs)
sell: p.sell, const activeRow = activeIndex == null ? null : data[activeIndex]
}))
return ( return (
<Stack gap="xs" data-testid="tibber-chart"> <Stack gap="xs" data-testid="tibber-chart">
<Title order={6} c="dimmed"> <Group gap="xs" justify="space-between" align="baseline">
Price curve ({currency}) <Title order={6} c="dimmed">
</Title> Price curve ({currency})
</Title>
{activeRow && (
<Text size="xs" c="dimmed" data-testid="tibber-current-price">
Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '}
{activeRow.sell.toFixed(4)}
</Text>
)}
</Group>
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}> <LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis <XAxis
dataKey="time" dataKey="ts"
tick={{ fontSize: 10 }} tick={{ fontSize: 10 }}
interval="preserveStartEnd" interval="preserveStartEnd"
tickFormatter={(v: string) => formatLocalTime(v)}
/> />
<YAxis <YAxis
tick={{ fontSize: 10 }} tick={{ fontSize: 10 }}
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
formatter={(val: any) => formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined] [`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
} }
labelFormatter={(label) =>
typeof label === 'string'
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
: label
}
/> />
<Legend /> <Legend />
<Line <Line
type="monotone" type="monotone"
dataKey="buy" dataKey="buy"
stroke="#2196f3" stroke={BUY_COLOR}
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
name="Buy" name="Buy"
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
<Line <Line
type="monotone" type="monotone"
dataKey="sell" dataKey="sell"
stroke="#4caf50" stroke={SELL_COLOR}
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
name="Sell" name="Sell"
/> />
{/* Currently active price slot, marked by default (no hover needed). */}
{activeRow && (
<ReferenceDot
x={activeRow.ts}
y={activeRow.buy}
r={4}
fill={BUY_COLOR}
stroke="#fff"
strokeWidth={2}
/>
)}
{activeRow && (
<ReferenceDot
x={activeRow.ts}
y={activeRow.sell}
r={4}
fill={SELL_COLOR}
stroke="#fff"
strokeWidth={2}
/>
)}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</Stack> </Stack>
+1 -1
View File
@@ -701,7 +701,7 @@
"api-energy" "api-energy"
], ],
"summary": "Get Prices", "summary": "Get Prices",
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total energy_tax sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).", "description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
"operationId": "get_prices_api_energy_prices_get", "operationId": "get_prices_api_energy_prices_get",
"parameters": [ "parameters": [
{ {
+3 -3
View File
@@ -527,9 +527,9 @@ paths:
\ (most recent first\n within the window, then reversed to ascending order\ \ (most recent first\n within the window, then reversed to ascending order\
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\ \ — identical to the\n modbus readings pattern).\n\n Response ``points``\
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\ \ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
\ price)\n - ``sell = total energy_tax sell_adjust`` (from active\ \ price)\n - ``sell = total energy_tax sell_fee sell_adjust`` (from\
\ version values)\n - ``level`` (Tibber price level,\ \ active version values)\n - ``level`` (Tibber price\
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\ \ level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\ manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
\ prices\n derived using the billing engine formula:\n - ``buy_dal \ \ prices\n derived using the billing engine formula:\n - ``buy_dal \
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\ \ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
+53 -1
View File
@@ -363,13 +363,65 @@ def test_prices_tibber_contract_returns_points(energy_client):
starts_at_list = [p["starts_at"] for p in body["points"]] starts_at_list = [p["starts_at"] for p in body["points"]]
assert starts_at_list == sorted(starts_at_list) assert starts_at_list == sorted(starts_at_list)
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0 # Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust
# (this version has no sell_fee/sell_adjust → both default to 0 at read time).
for p in body["points"]: for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6 assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4 assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
assert p["level"] == "NORMAL" assert p["level"] == "NORMAL"
def test_prices_tibber_sell_reflects_sell_fee(energy_client):
"""/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config."""
client, engine, _app = energy_client
_login(client)
# Net-metering version: sell_adjust = energy_tax (refund tax), sell_fee = 0.0248.
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Tibber NetMeter",
kind="tibber",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
session.add(
EnergyContractVersion(
contract_id=contract.id,
effective_from=now - timedelta(days=30),
effective_to=None,
values={
"energy": {
"energy_tax": 0.1108,
"sell_fee": 0.0248,
"sell_adjust": -0.1108,
},
"standing": {"management_fee": 5.99, "network_fee": 25.0},
"credits": {"heffingskorting": 600.0},
},
created_at=now,
)
)
session.commit()
_make_tibber_prices(engine, count=3)
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "tibber"
assert len(body["points"]) == 3
# sell = 0.245 0.1108 0.0248 (0.1108) = 0.245 0.0248 = 0.2202
for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - 0.2202) < 1e-4
def test_prices_tibber_limit_caps_results(energy_client): def test_prices_tibber_limit_caps_results(energy_client):
client, engine, _app = energy_client client, engine, _app = energy_client
_login(client) _login(client)
+18
View File
@@ -137,6 +137,11 @@ class TestLoadProfileTibber:
profile = load_profile("tibber") profile = load_profile("tibber")
assert profile.energy.sell_adjust.default == 0 assert profile.energy.sell_adjust.default == 0
def test_sell_fee_has_default_verkoopvergoeding(self) -> None:
profile = load_profile("tibber")
assert profile.energy.sell_fee.unit == "EUR/kWh"
assert profile.energy.sell_fee.default == 0.0248
def test_management_fee_has_default(self) -> None: def test_management_fee_has_default(self) -> None:
profile = load_profile("tibber") profile = load_profile("tibber")
assert profile.standing.management_fee.default is not None assert profile.standing.management_fee.default is not None
@@ -350,6 +355,19 @@ class TestValidateValuesTibber:
filled = validate_values("tibber", values) filled = validate_values("tibber", values)
assert filled["energy"]["sell_adjust"] == 0 assert filled["energy"]["sell_adjust"] == 0
def test_sell_fee_default_applied_when_absent(self) -> None:
values = {
"energy": {
"energy_tax": 0.1108,
"sell_adjust": 0.0,
# sell_fee absent — has default 0.0248 (verkoopvergoeding)
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
filled = validate_values("tibber", values)
assert filled["energy"]["sell_fee"] == 0.0248
def test_management_fee_default_applied_when_absent(self) -> None: def test_management_fee_default_applied_when_absent(self) -> None:
values = { values = {
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, "energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
+63 -1
View File
@@ -6,7 +6,7 @@ Acceptance criteria covered
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified). 2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
3. Manual strategy: Decimal precision no float binary rounding errors. 3. Manual strategy: Decimal precision no float binary rounding errors.
4. Tibber strategy: queries the most recent TibberPrice with starts_at t0. 4. Tibber strategy: queries the most recent TibberPrice with starts_at t0.
5. Tibber strategy: buy=total, sell=totalenergy_taxsell_adjust. 5. Tibber strategy: buy=total, sell=totalenergy_taxsell_feesell_adjust.
6. Tibber strategy: negative total negative export_revenue (not clamped). 6. Tibber strategy: negative total negative export_revenue (not clamped).
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists. 7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
8. ``register_strategy`` / ``get_strategy`` round-trip works. 8. ``register_strategy`` / ``get_strategy`` round-trip works.
@@ -371,6 +371,68 @@ class TestTibberStrategy:
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26 # sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
assert result["export_revenue"] == Decimal("2") * Decimal("0.13") assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
Under net metering the energy tax is refunded (sell_adjust = energy_tax),
so sell should equal total sell_fee. Verifies the fee is a first-class,
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
that is already baked into total.
"""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
session.commit()
# Net-metering config: sell_adjust = energy_tax refunds the tax;
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
values = {
"energy": {
"energy_tax": 0.11085,
"sell_fee": 0.0248,
"sell_adjust": -0.11085,
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.3073 0.11085 0.0248 (0.11085) = 0.3073 0.0248 = 0.2825
expected_sell = (
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
)
assert expected_sell == Decimal("0.2825")
assert result["export_revenue"] == Decimal("1") * expected_sell
assert result["pricing"]["sell_fee"] == "0.0248"
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
session.commit()
values = {
"energy": {"energy_tax": 0.10, "sell_adjust": 0.0}, # no sell_fee key
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.25 0.10 0 0 = 0.15
assert result["export_revenue"] == Decimal("0.15")
assert result["pricing"]["sell_fee"] == "0"
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None: def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
"""Correct row: starts_at ≤ t0, most recent wins.""" """Correct row: starts_at ≤ t0, most recent wins."""
t0 = _ts(10, 0) t0 = _ts(10, 0)
+90 -35
View File
@@ -69,22 +69,28 @@ _THREE_NODES = [
}, },
] ]
_PRICE_RANGE_RESPONSE = { def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
"data": { """Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
"viewer": { return {
"homes": [ "data": {
{ "viewer": {
"id": "home-id-1", "homes": [
"currentSubscription": { {
"priceInfoRange": { "id": "home-id-1",
"nodes": _THREE_NODES, "currentSubscription": {
} "priceInfo": {
}, "today": today,
} "tomorrow": tomorrow if tomorrow is not None else [],
] }
},
}
]
}
} }
} }
}
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
_CURRENT_PRICE_RESPONSE = { _CURRENT_PRICE_RESPONSE = {
"data": { "data": {
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
def test_fetch_price_range_does_not_assume_node_count(monkeypatch): def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
"""Parser handles an arbitrary number of nodes (not hardcoded to 96).""" """Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
# Build a response with a single node only. # Build a response with a single today node and no tomorrow yet.
one_node_response = { one_node_response = _price_info_response([_THREE_NODES[0]])
"data": {
"viewer": {
"homes": [
{
"id": "home-id-1",
"currentSubscription": {
"priceInfoRange": {
"nodes": [_THREE_NODES[0]],
}
},
}
]
}
}
}
transport = _make_transport(200, one_node_response) transport = _make_transport(200, one_node_response)
def _patched_post(url, *, json, headers, timeout): # noqa: A002 def _patched_post(url, *, json, headers, timeout): # noqa: A002
@@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
assert len(points) == 1 assert len(points) == 1
def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch):
"""today and tomorrow node lists are both parsed (today first, then tomorrow)."""
tomorrow_nodes = [
{
"startsAt": "2026-06-24T00:00:00.000+02:00",
"total": 0.40,
"energy": 0.32,
"tax": 0.08,
"currency": "EUR",
"level": "EXPENSIVE",
},
]
response = _price_info_response(_THREE_NODES, tomorrow_nodes)
transport = _make_transport(200, response)
def _patched_post(url, *, json, headers, timeout): # noqa: A002
client = httpx.Client(transport=transport)
return client.post(url, json=json, headers=headers, timeout=timeout)
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
points = fetch_price_range(_FAKE_TOKEN)
# 3 today + 1 tomorrow, in order.
assert len(points) == 4
# First node is today's first; last node is tomorrow's.
assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
# 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z
assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC)
assert points[-1].total == pytest.approx(0.40)
def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch):
"""A null tomorrow (before day-ahead publication) yields today's nodes only."""
response = {
"data": {
"viewer": {
"homes": [
{
"id": "home-id-1",
"currentSubscription": {
"priceInfo": {
"today": _THREE_NODES,
"tomorrow": None,
}
},
}
]
}
}
}
transport = _make_transport(200, response)
def _patched_post(url, *, json, headers, timeout): # noqa: A002
client = httpx.Client(transport=transport)
return client.post(url, json=json, headers=headers, timeout=timeout)
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
points = fetch_price_range(_FAKE_TOKEN)
assert len(points) == 3
def test_fetch_price_range_home_id_selection(monkeypatch): def test_fetch_price_range_home_id_selection(monkeypatch):
"""When home_id is specified, the matching home is selected.""" """When home_id is specified, the matching home is selected."""
two_homes_response = { two_homes_response = {
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
{ {
"id": "home-id-first", "id": "home-id-first",
"currentSubscription": { "currentSubscription": {
"priceInfoRange": { "priceInfo": {
"nodes": [_THREE_NODES[0]], "today": [_THREE_NODES[0]],
"tomorrow": [],
} }
}, },
}, },
{ {
"id": "home-id-second", "id": "home-id-second",
"currentSubscription": { "currentSubscription": {
"priceInfoRange": { "priceInfo": {
"nodes": [_THREE_NODES[1], _THREE_NODES[2]], "today": [_THREE_NODES[1], _THREE_NODES[2]],
"tomorrow": [],
} }
}, },
}, },