Compare commits
3
Commits
3e04b15656
...
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b65f700d56 | ||
|
|
f4cea3874b | ||
|
|
134f0abb5f |
@@ -30,20 +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(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
# 15-minute resolution.
|
||||||
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
|
#
|
||||||
# but the Tibber API typically starts from the current slot and returns at
|
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
|
||||||
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
# * ``today`` — always the full current local day (96 quarter-hourly slots,
|
||||||
# "today + tomorrow").
|
# 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:00–15: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 = """
|
_PRICE_RANGE_QUERY = """
|
||||||
{
|
{
|
||||||
viewer {
|
viewer {
|
||||||
homes {
|
homes {
|
||||||
id
|
id
|
||||||
currentSubscription {
|
currentSubscription {
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
priceInfo(resolution: QUARTER_HOURLY) {
|
||||||
nodes {
|
today {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
energy
|
energy
|
||||||
@@ -230,11 +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, first: 96)`` query
|
Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
|
||||||
and parses every returned node into a ``PricePoint``. The number of nodes
|
and parses every node from both lists (today first, then tomorrow) into a
|
||||||
is not assumed — all returned nodes are parsed regardless of count.
|
``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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -268,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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1263,6 +1263,9 @@ class TestSummarizePrincipleC:
|
|||||||
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
||||||
"""A fully future window (all local dates > today) counts 0 days.
|
"""A fully future window (all local dates > today) counts 0 days.
|
||||||
|
|
||||||
|
Pins ``local_now`` to June 25 2026 noon AMS so the 7/1→8/1 window is
|
||||||
|
genuinely in the future regardless of the actual wall-clock date
|
||||||
|
(mirrors the sibling window tests, which all pin ``local_now``).
|
||||||
Matches table row: 7/1→8/1 (all future) → 0 days.
|
Matches table row: 7/1→8/1 (all future) → 0 days.
|
||||||
"""
|
"""
|
||||||
eff_utc = _ams_midnight(2026, 6, 1)
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
@@ -1270,7 +1273,9 @@ class TestSummarizePrincipleC:
|
|||||||
|
|
||||||
start = _ams_midnight(2026, 7, 1)
|
start = _ams_midnight(2026, 7, 1)
|
||||||
end = _ams_midnight(2026, 8, 1)
|
end = _ams_midnight(2026, 8, 1)
|
||||||
result = self._run_summarize_ams(energy_db, start, end)
|
# Pin local_now to June 25 2026 noon AMS so 7/1→8/1 stays fully future.
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||||
|
|
||||||
assert result["fixed_costs"] == 0.0, (
|
assert result["fixed_costs"] == 0.0, (
|
||||||
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
||||||
|
|||||||
+80
-25
@@ -69,22 +69,28 @@ _THREE_NODES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRICE_RANGE_RESPONSE = {
|
def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
|
||||||
|
"""Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
|
||||||
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"homes": [
|
"homes": [
|
||||||
{
|
{
|
||||||
"id": "home-id-1",
|
"id": "home-id-1",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": _THREE_NODES,
|
"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": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user