CheapPower module

This fetches electricity prices and chooses the cheapest future time slot.
Currently, the only data source is the Nordpool prices for Finland, as
provided by ENTSO-E and https://sahkotin.fi. To use:

 * copy cheap_power.tapp to the file system
 * Invoke the Tasmota command CheapPower1, CheapPower2, … to
  * download prices for the next 24 to 48 hours
  * automatically choose the cheapest future time slot
  * to schedule Power1 ON, Power2 ON, … at the chosen slot
  * to install a Web UI in the main menu
 * For a full installation, you will want something like the following:
```
Backlog0 Timezone 99; TimeStd 0,0,10,1,4,120; TimeDst 0,0,3,1,3,180

Backlog0 SwitchMode1 15; SwitchTopic1 0
Backlog0 WebButton1 boiler; WebButton2 heat
PulseTime1 3700

Rule1 ON Clock#Timer DO CheapPower1 ENDON
Timer {"Enable":1,"Mode":0,"Time":"18:00","Window":0,"Days":"1111111","Repeat":1,"Output":1,"Action":3}
Rule1 1
Timers 1
```
The download schedule can be adjusted in the timer configuration menu.
The prices for the next day will typically be updated in the afternoon
or evening of the previous day.

In case the prices cannot be downloaded, the download will be retried
in 1, 2, 4, 8, 16, 32, 64, 64, 64, … minutes until it succeeds.

The user interface in the main menu consists of 4 buttons:
⏮ moves to the previous time slot (or wraps from the first to the last)
⏯ pauses (switches off) or chooses the optimal slot
🔄 requests the prices to be downloaded and the optimal slot to be chosen
⏭ moves to the next time slot (or wraps from the last to the first)

The status output above the buttons may also indicate that the output
is paused until further command or price update:
⭘
It may also indicate the start time and the price of the slot:
⭙ 2024-11-22 21:00	12.8 ¢

I am using this for controlling a 3×2kW warm water boiler.
For my usage, 1 hour every 24 or 48 hours is sufficient.
This commit is contained in:
Marko Mäkelä 2024-12-15 21:00:34 +02:00
parent b3b9699782
commit 555dc04846
3 changed files with 165 additions and 0 deletions

Binary file not shown.

View File

@ -0,0 +1,11 @@
var wd = tasmota.wd
tasmota.add_cmd("CheapPower",
def (cmd, idx)
import sys
var path = sys.path()
path.push(wd)
import cheap_power
path.pop()
cheap_power.start(idx)
end
)

View File

@ -0,0 +1,154 @@
import webserver
import json
var cheap_power = module("cheap_power")
cheap_power.init = def (m)
class CheapPower
var prices # future prices for up to 48 hours
var times # start times of the prices
var timeout# timeout until retrying to update prices
var chosen # the chosen time slot
var channel# the channel to control
var tz # the current time zone offset from UTC
static var PAST = -3600 # minimum timer start age
static var MULT = .1255 # conversion to ¢/kWh including 25.5% VAT
static var PREV = 0, PAUSE = 1, UPDATE = 2, NEXT= 3
static var UI = "<table style='width:100%'><tr>"
"<td style='width:25%'><button onclick='la(\"&op=0\");'>⏮</button></td>"
"<td style='width:25%'><button onclick='la(\"&op=1\");'>⏯</button></td>"
"<td style='width:25%'><button onclick='la(\"&op=2\");'>🔄</button></td>"
"<td style='width:25%'><button onclick='la(\"&op=3\");'>⏭</button></td>"
"</tr></table>"
static var URL0 = 'http://sahkotin.fi/prices?start=', URL1 = '&end='
static var URLTIME = '%Y-%m-%dT%H:00:00.000Z'
def init()
self.prices = []
self.times = []
end
def start(idx)
if idx == nil || idx < 1 || idx > tasmota.global.devices_present
tasmota.log(f"CheapPower{idx} is not a valid Power output")
tasmota.resp_cmnd_failed()
else
self.channel = idx - 1
tasmota.add_driver(self)
self.update()
tasmota.resp_cmnd_done()
end
end
def power(on) tasmota.set_power(self.channel, on) end
# fetch the prices for the next 24 to 48 hours
def update()
var wc = webclient()
var rtc = tasmota.rtc()
self.tz = rtc['timezone'] * 60
var now = rtc['utc']
var url = self.URL0 +
tasmota.strftime(self.URLTIME, now) + self.URL1 +
tasmota.strftime(self.URLTIME, now + 172800)
wc.begin(url)
var rc = wc.GET()
if rc == 200
var data = json.load(wc.get_string())
wc.close()
if data data = data.find('prices') end
var prices = [], times = []
if data
for i: data.keys()
var datum = data[i]
prices.push(self.MULT * datum['value'])
times.push(tasmota.strptime(datum['date'],
'%Y-%m-%dT%H:%M:%S.000Z')['epoch'])
end
self.timeout = nil
self.prices = prices
self.times = times
self.schedule_chosen(self.find_cheapest(), now, self.PAST)
return
end
else
wc.close()
print(f'error {rc} for {url}')
end
# We failed to update the prices. Retry in 1, 2, 4, 8, …, 64 minutes.
if !self.timeout
self.timeout = 60000
elif self.timeout < 3840000
self.timeout = self.timeout * 2
end
tasmota.set_timer(self.timeout, /->self.update())
end
# determine the cheapest slot
def find_cheapest()
var cheapest, N = size(self.prices)
if N
cheapest = 0
for i: 1..N-1
if self.prices[i] < self.prices[cheapest] cheapest = i end
end
end
return cheapest
end
def date_from_now(chosen, now) return self.times[chosen] - now end
# trigger the timer at the chosen hour
def schedule_chosen(chosen, now, old)
tasmota.remove_timer('power_on')
var d = chosen == nil ? self.PAST : self.date_from_now(chosen, now)
if d != old self.power(d > self.PAST && d <= 0) end
if d > 0
tasmota.set_timer(d * 1000, def() self.power(true) end, 'power_on')
elif d <= self.PAST
chosen = nil
end
self.chosen = chosen
end
def web_add_main_button() webserver.content_send(self.UI) end
def web_sensor()
var ch, old = self.PAST, now = tasmota.rtc()['utc']
var N = size(self.prices)
if N
ch = self.chosen
if ch != nil && ch < N old = self.date_from_now(ch, now) end
while N
if self.date_from_now(0, now) > self.PAST break end
ch = ch ? ch - 1 : nil
self.prices.pop(0)
self.times.pop(0)
N -= 1
end
end
var op = webserver.has_arg('op') ? int(webserver.arg('op')) : nil
if op == self.UPDATE
self.update()
ch = self.chosen
end
if !N
elif op == self.PAUSE
ch = ch == nil ? self.find_cheapest() : nil
elif op == self.PREV
ch = (!ch ? N : ch) - 1
elif op == self.NEXT
ch = ch != nil && ch + 1 < N ? ch + 1 : 0
end
self.schedule_chosen(ch, now, old)
var status = ch == nil
? '{s}⭘{m}{e}'
: format('{s}⭙ %s{m}%.3g ¢{e}',
tasmota.strftime('%Y-%m-%d %H:%M', self.tz + self.times[ch]),
self.prices[ch])
tasmota.web_send_decimal(status)
end
end
return CheapPower()
end
return cheap_power