Skip to content

Commit 41d33a9

Browse files
Markus Rebbertclaude
andcommitted
feat: grid-cost breakdown attributes on current_electricity_price
Surface the price decomposition (SDK audit item, live-reconciled 2026-07-12) as attributes on the current-price sensor: spot_price, grid_costs (net adder), grid_cost_components, vat_rate, uses_fallback_grid_costs. Exact layering: all_in = (spot + net grid components) x (1 + vat). The itemised components are net (ex-VAT); the SDK scalar grid_costs_total is gross and intentionally NOT surfaced, so spot + grid_costs reconciles to the pre-VAT price (the netto-vs-brutto gotcha from the reconciliation). No new entity/coordinator/translations. README attribute table + CHANGELOG. 199 tests green (+2, incl. an explicit reconciliation assertion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6bdbe02 commit 41d33a9

4 files changed

Lines changed: 143 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
99
- `sensor.<sys>_daily_savings` — today's cloud-computed energy savings (€), read from the previously-unused `get_energy_today()` endpoint (`EnergyData.savings_eur`). Daily running total that resets at local midnight (`state_class=total`, `last_reset` = start of local day), so Long-Term Statistics records one cycle per day. New `OneKomma5EnergyCoordinator` (15-min interval) backs it, with a matching `diag_energy_update` diagnostic timestamp. Currency follows the resolved account currency.
1010
- `sensor.<sys>_ev_battery_capacity` — the vehicle's nominal battery capacity (kWh), from the EV profile's `capacity_wh`.
1111
- `sensor.<sys>_ev_scheduled_departure_soc` — the departure schedule's target SoC (%), distinct from the manual `ev_target_soc`. Read-only.
12+
- Grid-cost breakdown attributes on `sensor.<sys>_current_electricity_price`: `spot_price`, `grid_costs`, `grid_cost_components`, `vat_rate`, `uses_fallback_grid_costs`. Decomposes the all-in price as `(spot + net grid costs) × (1 + vat)` — components are net (ex-VAT); the SDK's VAT-inclusive `grid_costs_total` is intentionally not surfaced.
1213
- Translations for all new entities across all seven shipped locales.
1314

1415
## [0.1.47] - 2026-07-05

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,13 @@ The **Current Electricity Price** sensor carries several attributes updated ever
220220
| `forecast` | Rolling 24-hour price forecast (list, see below) |
221221
| `cheapest_future_hour` | ISO-8601 start timestamp of the cheapest upcoming slot |
222222
| `cheapest_future_price` | Price (EUR/kWh) of that slot |
223+
| `spot_price` | Raw market/exchange price of the active slot (net, ex grid costs & VAT) |
224+
| `grid_costs` | Net grid-cost adder (energy tax + purchasing + fixed tariff + dynamic markup + feed-in adjustment) |
225+
| `grid_cost_components` | The above broken out per component (all net / ex-VAT) |
226+
| `vat_rate` | VAT rate applied on top (e.g. `0.19`) |
227+
| `uses_fallback_grid_costs` | `true` when the grid costs are estimated rather than contract-exact |
228+
229+
The price decomposes exactly as `all_in = (spot_price + grid_costs) × (1 + vat_rate)` — so these attributes answer "why is my kWh this price?". Note that `spot_price` varies per 15-min slot while the grid-cost components are flat daily constants.
223230

224231
The sensor value always reflects the **active 15-minute slot** (smallest slot end > now), not just the price at the top of the hour. The `forecast` list covers up to **30 hours** ahead (today + all of tomorrow) and is compatible with [`apexcharts-card`](https://github.com/RomRider/apexcharts-card) and other custom cards that follow the Tibber/ENTSO-E format:
225232

custom_components/onekommafive/sensor_entities.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
QuarterHourUpdateMixin,
3535
system_device_info,
3636
)
37-
from .helpers import find_cheapest_window, trapezoidal_delta_kwh
37+
from .helpers import find_cheapest_window, get_current_price, trapezoidal_delta_kwh
3838
from .sensor_descriptions import (
3939
OneKomma5EVSensorDescription,
4040
OneKomma5OptimizationSensorDescription,
@@ -127,7 +127,7 @@ def native_value(self) -> Any:
127127

128128
@property
129129
def extra_state_attributes(self) -> dict[str, Any] | None:
130-
"""Expose the price forecast on the current-price sensor."""
130+
"""Expose the forecast + grid-cost breakdown on the current-price sensor."""
131131
if self.entity_description.key != "current_electricity_price":
132132
return None
133133
if self.coordinator.data is None:
@@ -138,8 +138,41 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
138138
cheapest = min(forecast, key=lambda s: s["price"])
139139
attrs["cheapest_future_hour"] = cheapest["start"]
140140
attrs["cheapest_future_price"] = cheapest["price"]
141+
attrs.update(self._price_breakdown(self.coordinator.data))
141142
return attrs
142143

144+
def _price_breakdown(self, data: Any) -> dict[str, Any]:
145+
"""Decompose the current all-in price into spot + net grid costs + VAT.
146+
147+
Per-slot layering is exact:
148+
``all_in = (spot + Σ net grid components) × (1 + vat)``.
149+
The grid components are net (ex-VAT) daily constants — only the spot
150+
part varies per slot. The SDK's scalar ``grid_costs_total`` is the
151+
VAT-inclusive figure and is intentionally NOT surfaced here; we expose
152+
the net adder so ``spot + grid_costs`` reconciles to the pre-VAT price.
153+
"""
154+
mp = getattr(data, "market_prices", None)
155+
if mp is None:
156+
return {}
157+
spot = get_current_price(getattr(mp, "prices", None) or {})
158+
components = {
159+
"energy_tax": getattr(mp, "grid_cost_energy_tax", None),
160+
"purchasing": getattr(mp, "grid_cost_purchasing", None),
161+
"fixed_tariff": getattr(mp, "grid_cost_fixed_tariff", None),
162+
"dynamic_markup": getattr(mp, "grid_cost_dynamic_markup", None),
163+
"feed_in_remuneration_adjustment": getattr(
164+
mp, "grid_cost_feed_in_remuneration_adj", None
165+
),
166+
}
167+
present = {k: v for k, v in components.items() if v is not None}
168+
return {
169+
"spot_price": round(spot, 6) if spot is not None else None,
170+
"grid_costs": round(sum(present.values()), 6) if present else None,
171+
"grid_cost_components": present,
172+
"vat_rate": getattr(mp, "vat", None),
173+
"uses_fallback_grid_costs": getattr(mp, "uses_fallback_grid_costs", None),
174+
}
175+
143176

144177
class OneKomma5CheapestChargingWindowSensor(OneKomma5PriceEntity, RestoreSensor):
145178
"""Cheapest N-min charging window that still ends today (local time).
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tier-2 tests for the grid-cost breakdown attributes on the current-price sensor.
2+
3+
Live reconciliation (2026-07-12): all_in = (spot + Σ net grid components) × (1 + vat).
4+
The itemised components are NET (ex-VAT); the SDK scalar grid_costs_total is GROSS
5+
and is intentionally NOT surfaced. The sensor exposes the net adder so
6+
`spot_price + grid_costs` reconciles to the pre-VAT price.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import datetime
12+
from unittest.mock import MagicMock, patch
13+
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.helpers import entity_registry as er
16+
from pytest_homeassistant_custom_component.common import MockConfigEntry
17+
18+
from custom_components.onekommafive.const import (
19+
CONF_PASSWORD,
20+
CONF_SYSTEM_ID,
21+
CONF_USERNAME,
22+
DOMAIN,
23+
)
24+
25+
SPOT = 0.126
26+
ENERGY_TAX = 0.13756
27+
VAT = 0.19
28+
ALL_IN = round((SPOT + ENERGY_TAX) * (1 + VAT), 7) # 0.3136364
29+
30+
31+
def _market_prices(*, uses_fallback: bool = False) -> MagicMock:
32+
slot = (
33+
(datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(hours=1))
34+
.isoformat()
35+
.replace("+00:00", "Z")
36+
)
37+
return MagicMock(
38+
prices={slot: SPOT},
39+
prices_with_grid_costs={slot: round(SPOT + ENERGY_TAX, 7)},
40+
prices_with_grid_costs_and_vat={slot: ALL_IN},
41+
average_price_all_in=ALL_IN,
42+
lowest_price_all_in=ALL_IN,
43+
highest_price_all_in=ALL_IN,
44+
grid_cost_energy_tax=ENERGY_TAX,
45+
grid_cost_purchasing=0.0,
46+
grid_cost_fixed_tariff=0.0,
47+
grid_cost_dynamic_markup=0.0,
48+
grid_cost_feed_in_remuneration_adj=0.0,
49+
vat=VAT,
50+
uses_fallback_grid_costs=uses_fallback,
51+
)
52+
53+
54+
async def _setup(hass: HomeAssistant, prices: MagicMock, mock_system_factory) -> None:
55+
system = mock_system_factory(system_id="sys-1", prices=prices)
56+
entry = MockConfigEntry(
57+
domain=DOMAIN,
58+
unique_id="sys-1",
59+
data={CONF_USERNAME: "u@x.de", CONF_PASSWORD: "pw", CONF_SYSTEM_ID: "sys-1"},
60+
)
61+
entry.add_to_hass(hass)
62+
with (
63+
patch("onekommafive.systems.Systems") as mock_systems_cls,
64+
patch("onekommafive.client.Client"),
65+
):
66+
mock_systems_cls.return_value.get_system.return_value = system
67+
mock_systems_cls.return_value.get_systems.return_value = [system]
68+
await hass.config_entries.async_setup(entry.entry_id)
69+
await hass.async_block_till_done()
70+
71+
72+
def _current_price_state(hass: HomeAssistant):
73+
entity_id = er.async_get(hass).async_get_entity_id(
74+
"sensor", "onekommafive", "sys-1_current_electricity_price"
75+
)
76+
assert entity_id is not None
77+
return hass.states.get(entity_id)
78+
79+
80+
async def test_breakdown_attributes_reconcile(hass: HomeAssistant, mock_system_factory) -> None:
81+
await _setup(hass, _market_prices(), mock_system_factory)
82+
attrs = _current_price_state(hass).attributes
83+
84+
assert attrs["spot_price"] == SPOT
85+
assert attrs["grid_costs"] == ENERGY_TAX
86+
assert attrs["vat_rate"] == VAT
87+
assert attrs["uses_fallback_grid_costs"] is False
88+
assert attrs["grid_cost_components"]["energy_tax"] == ENERGY_TAX
89+
# zero components are surfaced (present, not None), not dropped
90+
assert attrs["grid_cost_components"]["dynamic_markup"] == 0.0
91+
92+
# The exposed net figures must reconcile to the all-in state.
93+
reconstructed = round((attrs["spot_price"] + attrs["grid_costs"]) * (1 + attrs["vat_rate"]), 7)
94+
assert reconstructed == ALL_IN
95+
96+
97+
async def test_fallback_flag_surfaces_true(hass: HomeAssistant, mock_system_factory) -> None:
98+
await _setup(hass, _market_prices(uses_fallback=True), mock_system_factory)
99+
attrs = _current_price_state(hass).attributes
100+
assert attrs["uses_fallback_grid_costs"] is True

0 commit comments

Comments
 (0)