Heating water when electricity is cheap

The data is in place, and I trust my deployment, from Compose files in git to one Ansible command. Time to actually do something with it.

A tariff that follows the exchange

Since July 2025 I am with AAE Natur.spot, before that I had the hourly tariff of aWATTar. With a dynamic tariff, the price per kWh follows the day-ahead price of the electricity exchange (EPEX Spot), plus a fixed surcharge and VAT. Negative prices are passed through, so there are hours where I get money for using power. The prices for the next day are published around noon, so I always know them until the end of tomorrow.

And the difference is big. The cheapest hours are usually around noon, when solar floods the market, and at night. The most expensive ones are in the early evening. Since 1 October 2025 the European day-ahead market even trades quarter hours instead of hours, so there are 96 prices per day.

Getting the prices into openHAB

openHAB has an aWATTar binding that fetches these prices. There was just one problem. The aWATTar API delivers hourly prices:

curl -s https://api.awattar.at/v1/marketdata | jq '.data[0] | (.end_timestamp - .start_timestamp) / 60000'
# 60

So I extended the binding. Energy-Charts by Fraunhofer ISE publishes the day-ahead prices, quarter hours included, through a free API. I added it as a second provider, with a common MarketPriceApi for both, a provider option on the bridge and unit tests for the new parser (pull request).

Bridge awattar:bridge:bridge1 "aWATTar Bridge" [ country="AT", vatPercent="20", basePrice="1.3", provider="energy-charts" ] {
    Thing prices price1 "aWATTar Price" []
}

In the very first week, the Energy-Charts API was down for almost three days (HTTP 503), and no prices for the next day arrived. Switching the bridge back to provider="awattar" kept everything running on hourly prices until it was back. So having two sources paid off immediately.

Asking Google for the price

A small hack I really like. Google Home has no device type for electricity prices, but it has air quality sensors. So a rule rounds the current price to cents

rule "Price level for Google"
when
    System started or Item TotalNet changed
then
    if (TotalNet.state instanceof Number) {
        // EUR/kWh -> ct/kWh, e.g. 0.18 -> 18
        TotalNetCents.postUpdate(Math.round((TotalNet.state as Number).floatValue * 100.0))
    }
end

and the item pretends to be a CO₂ sensor:

Number TotalNetCents "Electricity price [%.1f ct/kWh]" { ga="Sensor" [ sensorName="CarbonDioxideLevel", valueUnit="PARTS_PER_MILLION" ] }

“Hey Google, what is the CO₂ level?” now answers with the electricity price in cents. The unit is wrong, the number is right, and nobody in the house cares 🙂

The price I really pay

The exchange price is only part of the bill. On top come the grid fee, the grid losses, a few levies, the electricity tax and VAT, about 10 cents per kWh in total. Most of it is the same in every quarter hour, so it does not change which hours are cheapest. With one exception. In Austria the grid fee is lower in summer between 10:00 and 16:00, the “Sommer-Nieder-Arbeitspreis” (SNAP). At my grid operator, Wiener Netze, that is 5.58 instead of 6.98 ct per kWh, net.

So a Flux task builds the price I really pay, from my invoices. The fees are a table, each row valid from its date until the next one:

fees = [
    {
        from: 2026-03-01T00:00:00+01:00,
        network: 6.98, losses: 0.70, eag: 0.620, tax: 0.1,
        hasSnap: true, snap: 5.58, snapMonthFrom: 4, snapMonthTo: 9, snapHourFrom: 10, snapHourTo: 16,
        metering: 0.0717, gridFlat: 0.1479, eagFlat: 0.0625, baseFee: 1.80, levy: 0.07,
    },
    ...
]

For every quarter hour it takes the exchange price with the surcharge, the grid fee of that time and the rest of the table:

_value: ((r._value * 100.0 + network + f.losses) * (1.0 + f.levy) + f.eag + f.tax) * vat / 100.0

The result goes to InfluxDB twice. As PricePaid_EURkWh for the dashboards, and as PricePaidForecast, written in the format of openHAB persistence. For openHAB that is simply the forecast of a Number item:

Number PricePaidForecast "Paid price [%.3f EUR/kWh]" <price>

When a new invoice brings new fees, I add a row, and every window from then on uses them.

Heating the water

The boiler hangs on a Shelly Pro 1PM (ad), a relay for the distribution board. Its switch is the item RelayOutput.

The boiler holds about 100 litres at 55 to 60 °C and draws about 2 kW. It needs about 4.7 kWh a day on average, so 4 hours are always enough to heat it fully.

The aWATTar binding has bestprice things that find such a window, but they count the length in price intervals, so 4 hours are length="4" with hourly prices and length="16" with quarter hours, and they only see the prices of that binding. So I wrote an add-on for openHAB that finds the cheapest window in any forecast item (pull request, it gets its own post once it is merged). The boiler gets the cheapest 4 hours between 04:00 and midnight, as a rule in a YAML file:

version: 1
rules:
  optimalwindow:boiler:
    label: Optimal window water boiler
    triggers:
      - id: "1"
        type: optimalwindow.WindowTrigger
        config:
          forecastItem: PricePaidForecast
          rangeStart: "04:00"
          rangeDuration: 20h
          length: 4h
          activeItem: BoilerControlEveningActive
          startItem: BoilerControlEveningStart
          endItem: BoilerControlEveningEnd
    conditions:
      # no ON during holidays, OFF always passes
      - id: "3"
        type: script.ScriptCondition
        config:
          type: application/javascript
          script: |
            !(command.toString() === 'ON' && items.ScenarioTriggered.state === 'Urlaub')
    actions:
      # empty command: ON or OFF from the trigger
      - id: "2"
        type: core.ItemCommandAction
        config:
          itemName: RelayOutput

The trigger fires when the window starts and when it ends, and passes ON or OFF as command. The action sends it to the relay. The condition only blocks the ON while we are on holiday, so a boiler that is heating is always switched off at the end.

On a sunny day the cheapest window is usually around noon. Nice side effect: that is exactly when the balcony PV produces, so the boiler uses energy I would otherwise give away. And in summer the SNAP hours are right there as well.

Washing machine and dishwasher

These two I cannot switch, I have to start them myself. But both have a timer. The washing machine has “ready in” (we use a 1:30 h program), the dishwasher a start delay (3:30 h). So each gets its own window rule, without an action, and a small rule turns the window into the value to set:

val ds = if (DishwasherControlStart.state instanceof DateTimeType) (DishwasherControlStart.state as DateTimeType).getZonedDateTime(ZoneId.systemDefault()) else null
if (ds !== null && ds.isAfter(now)) {
    val h = Math.round(Duration.between(now, ds).toMinutes / 60.0)
    DishwasherDelaySetting.postUpdate(h.toString + " h (starts " + String.format("%1$tH:%1$tM", ds) + ")")
}

The app shows at any time what to set, like “3 h (starts 01:45)”. The dishwasher window runs from 14:00 to 14:00, because the prices for the next day are there from about 13:00. If its window starts at night, a push at 21:00 tells me the delay before I go to bed. For a window by day, there is a push 15 minutes before.

The sitemap in the evening: the boiler is heating until midnight, the washing machine is set to "ready in 17 h", the dishwasher to a start delay of 3 h, below the prices of the next hours

Both machines now also hang on a Bosch compact smart plug (ad). They only measure. A rule pushes when a run is done, with the duration, the energy and what it cost at the paid price, and reminds me when a window by day started without a run.

Does it pay off?

For the boiler I have all quarter hours since January 2025. The old setup simply reheated whenever we took hot water, so mostly in the morning and in the evening, the most expensive time of the day. With the AAE price and the fees of my invoices for the whole time, the controlled boiler cost 21.2 ct/kWh on average. The same energy at the times we use the water, with the standby losses spread over the day, would have cost about 284 EUR more since January 2025. That is about 130 EUR for 2025, and about 215 EUR a year at the rate of 2026, which is higher because of SNAP and because the windows use the paid price.

Two things I expected to help, but they do not:

  • The boiler does not need one block, it could also heat in the 16 cheapest single quarter hours of the day. Over a year that is an energy price of 7.67 instead of 7.96 ct/kWh, about 5 EUR a year more. On most days the cheap hours are next to each other anyway, but the relay in the Shelly would switch about 580 instead of 365 times a year, on some days up to 9 times, always with 2 kW on it. For 5 EUR I would rather not stress it.
  • SNAP changed the boiler window on only 7 of 365 days, and the whole year saved 9 cents. The summer midday is already the cheapest time because of all the solar power in the grid, so the window is there anyway. It still counts in the cost, just not in the choice of the window.

So the boiler now runs when power is cheap. But it is still a 2 kW load, and when it runs together with the oven, the house pulls more power than I would like.

Links marked (ad) are Amazon affiliate links. If you buy through them, I get a small commission, the price stays the same for you. As an Amazon Associate I earn from qualifying purchases (Als Amazon-Partner verdiene ich an qualifizierten Verkäufen).

Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *