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).
This commit is contained in:
2026-07-18 20:20:33 +02:00
parent f4cea3874b
commit b65f700d56
2 changed files with 132 additions and 57 deletions
+42 -22
View File
@@ -30,26 +30,39 @@ logger = logging.getLogger(__name__)
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
_DEFAULT_TIMEOUT = 15.0
# GraphQL query to fetch a range of 15-minute price nodes.
# ``priceInfoRange`` is a Relay-style cursor connection over the subscription's
# entire available price history. With no before/after cursor:
# * ``first: N`` returns the OLDEST N nodes (anchored at the subscription
# start date) — a fixed window that never advances, so it is WRONG for
# "current + upcoming" prices.
# * ``last: N`` returns the NEWEST N nodes (ending at the latest published
# slot: tomorrow 23:45 once day-ahead prices are out, else today 23:45).
# 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
# queries (the earliest of the 192 newest slots reaches back past today 00:00
# UTC in either publish state).
# GraphQL query to fetch the forward-looking today + tomorrow price curve at
# 15-minute resolution.
#
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
# * ``today`` — always the full current local day (96 quarter-hourly slots,
# 00:00 → 23:45 local), regardless of the current time.
# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
# day-ahead prices (around 13:0015:00 local); empty before that.
#
# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
# connection whose range ends at "now" (it never returns future slots), so it
# 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 = """
{
viewer {
homes {
id
currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, last: 192) {
nodes {
priceInfo(resolution: QUARTER_HOURLY) {
today {
startsAt
total
energy
tax
currency
level
}
tomorrow {
startsAt
total
energy
@@ -236,13 +249,15 @@ def fetch_price_range(
*,
timeout: float = _DEFAULT_TIMEOUT,
) -> 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
and parses every returned node into a ``PricePoint``. ``last`` (not
``first``) is used so the newest slots are returned; ``first`` would anchor
at the subscription start date and never advance. The number of nodes is
not assumed — all returned nodes are parsed regardless of count.
Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
and parses every node from both lists (today first, then tomorrow) into a
``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
full current local day and ``tomorrow`` is populated once Tibber publishes the
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
----------
@@ -276,10 +291,15 @@ def fetch_price_range(
home = _pick_home(homes, home_id)
try:
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
price_info = home["currentSubscription"]["priceInfo"]
today = price_info["today"]
tomorrow = price_info["tomorrow"]
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]