-
Notifications
You must be signed in to change notification settings - Fork 2
/
ESSController.py
executable file
·396 lines (359 loc) · 17.4 KB
/
ESSController.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
#!/usr/bin/env python
# coding: utf-8
# Simple script for charging battery when prices are low
# In this scenario the battery will be charged when prices are x percent below the dialy average.
# The limit can be set by the lowThreshold variable.
# Default is 0.8, which means that charging will start when current price is 20% below the daily average.
#
# To get this script working you need to provide (best to edit the secret.py file):
# * vrmID
# * password
# * username
#
# Retrieving data is now only available for ANWB, other providers will be added later.
import requests, datetime, pytz, base64, time, logzero, schedule, xmltodict
from datetime import datetime, timedelta
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.lines import Line2D
import numpy as np
import paho.mqtt.client as mqtt
from logzero import logger
global dfPrices, flagConntected, lowThreshold, highThreshold, dateToday, tz, plotImage, defaultGridSetpoint, chargingGridSetpoint, password, vrmID, username, lastChargeCondition, lastDischargeCondition, PV, locLat, locLong, angle, direction , totPower, chargeMode, chargeHours
from secret import password, vrmID, username
# Settings
dateToday = 1; # If 1, date is today, if 0 date is tomorrow, for testing only
tz = "Europe/Amsterdam" # Time zone
plotImage = 1 # If true image get created
defaultGridSetpoint = 30 # Default grid point (Watt)
chargingGridSetpoint = 3000 # Charging grid point (Watt)
provider = 1 #0 = ANWB, 1 = ENTSOE
# Secrets
# vrmID = "" # VRM ID, if not imported from secret.py put it here
# username = "" #VRM Username, if not imported from secret.py put it here
# Save base64 encoded password to sectret.py.
# For testing you can write your password here in plain text but not recommended
password = base64.b64decode(password).decode("utf-8") # Retrieve password from secrets.py
# Buy & sell modes
# 1 = Charge when current price < X average price
# 2 = Charge when current price < X average price, discharge when price > X average price
# 3 = Charge during cheapest x hours
chargeMode = 1
# Mode 1 settings
lowThreshold = 0.6 # Constant to set the low threshold for charging
# Mode 2 settings
highThreshold = 1.4 # Constant to set the high threshold for selling
# Mode 3 settings
chargeHours = 3
#PV Settings
PV = 1 # if PV = 1, PV Aware charging is enabled. PV = 0 is off
locLat = "51.33.36" # Latitude
locLong = "5.5.60" # Longitude
angle = 40 # Angle of your panels 0 (horizontal) … 90 (vertical)
direction = 90 # Plane azimuth, -180 … 180 (-180 = north, -90 = east, 0 = south, 90 = west, 180 = north)
totPower = 1 # installed modules power in kilo watt
# Set up MQTT Broker
global client
def on_connect(client, userdata, flags, rc):
client.subscribe("$SYS/#")
global flagConntected
flagConntected = 1
logger.info("Broker connected.")
def on_message(client, userdata, msg):
print(msg.topic+" "+str(msg.payload))
def on_publish(client, userdata, mid):
logger.info("Message Published.")
def on_disconnect(client, userdata, rc):
global flagConntected
flagConntected = 0
logger.info("Broker disconnected.")
client = mqtt.Client("client12312313")
flagConntected = 0
client.on_connect = on_connect
client.on_message = on_message
client.on_publish = on_publish
client.on_disconnect = on_disconnect
client.tls_set("venus-ca.crt")
client.username_pw_set(username, password)
# Setup logfile
logzero.logfile("log.log", maxBytes=1e6, backupCount=3)
# Calculate MQTT Broker URL
def calculateBroker(vrmID):
sum2 = 0
for character in vrmID.lower().strip():
sum2 += ord(character)
broker_index = sum2 % 128
brokerURL = "mqtt{}.victronenergy.com".format(broker_index)
return brokerURL
def setDefaultSetpoint():
global defaultGridSetpoint, lastChargeCondition, lastDischargeCondition
logger.info("Set default setpoint: " + str(defaultGridSetpoint) + " watt")
# Control ESS over MQTT
client.connect(calculateBroker(vrmID), 8883, keepalive=60)
client.loop_start()
# Wait for connecting
while not flagConntected:
time.sleep(1)
client.publish("W/" + vrmID + "/settings/0/Settings/CGwacs/AcPowerSetPoint", '{"value":' + str(defaultGridSetpoint) + '}')
lastChargeCondition = 0
lastDischargeCondition = 0
client.loop_stop()
def setChargeSetpoint():
global chargingGridSetpoint, lastChargeCondition
logger.info("Set charge setpoint: " + str(chargingGridSetpoint) + " watt")
# Control ESS over MQTT
client.connect(calculateBroker(vrmID), 8883, keepalive=60)
client.loop_start()
# Wait for connecting
while not flagConntected:
time.sleep(1)
client.publish("W/" + vrmID + "/settings/0/Settings/CGwacs/AcPowerSetPoint", '{"value":' + str(chargingGridSetpoint) + '}')
lastChargeCondition = 1
client.loop_stop()
def setDishargeSetpoint():
global chargingGridSetpoint, lastDischargeCondition
logger.info("Set discharge setpoint: " + str(-1*chargingGridSetpoint) + " watt")
# Control ESS over MQTT
client.connect(calculateBroker(vrmID), 8883, keepalive=60)
client.loop_start()
# Wait for connecting
while not flagConntected:
time.sleep(1)
client.publish("W/" + vrmID + "/settings/0/Settings/CGwacs/AcPowerSetPoint", '{"value":' + str(-1*chargingGridSetpoint) + '}')
lastDischargeCondition = 1
client.loop_stop()
# Init misc vars
lastChargeCondition = 0
lastDischargeCondition = 0
# Retrieve prices
def getPrices():
if dateToday:
dayString = "today"
else:
dayString = "tomorrow"
#In case of ANWB
if provider == 0:
# Retrieve prices
global dfPrices, averagePrice
# Set dates of interest in correct format for API
fromDate = datetime.now().replace(microsecond=0, second=0, minute=0, hour=0)
tillDate = datetime.now().replace(microsecond=0, second=0, minute=0, hour=0)+timedelta(days=1)-timedelta(seconds=1)
if dateToday == 0:
fromDate = fromDate+timedelta(days=1)
tillDate = tillDate+timedelta(days=1)
# Add time zone info to dates
fromDateTZ = fromDate.astimezone(pytz.timezone(tz))
fromDateTZ = fromDateTZ.astimezone(pytz.utc)
tillDateTZ = tillDate.astimezone(pytz.timezone(tz))
tillDateTZ = tillDateTZ.astimezone(pytz.utc)
# Reformat dates for correct format for API
fromDateString = fromDateTZ.isoformat().replace("+00:00","Z")
tillDateString = tillDateTZ.isoformat().replace("+00:00","Z")
# Build API URL
url = "https://api.energyzero.nl/v1/energyprices?fromDate=" + fromDateString + "&tillDate=" + tillDateString + "&interval=4&usageType=1&inclBtw=true"
# Excecute API request
try:
prices = requests.get(url)
except Exception as e:
logger.error("Can't retrieve prices.")
return
try:
pricesDoc = prices.json()
except Exception as e:
logger.exception(e)
logger.error("Can't retrieve prices.")
# Add prices to Pandas dataframe
dfPrices = pd.DataFrame.from_dict(pricesDoc["Prices"])
# Convert strings to datetime
dfPrices['readingDate'] = pd.to_datetime(dfPrices['readingDate'])
# Add timezone info to dates and calculate local times
dfPrices['localDate'] = dfPrices['readingDate'].dt.tz_convert(tz)
# Calculate average price
averagePrice = round(dfPrices["price"].mean()*100)/100
# Add column with charge condition based on lower limit.
logger.info("Now prices retrieved for " + dayString)
logger.info("Average price is: €" + '%.2f' % averagePrice)
# In case of ENTSOE
if provider == 1:
from secret import entsoeKey
fromDate = datetime.now().replace(microsecond=0, second=0, minute=0, hour=0)
tillDate = datetime.now().replace(microsecond=0, second=0, minute=0, hour=0)+timedelta(days=1)
if dateToday == 0:
fromDate = fromDate+timedelta(days=1)
tillDate = tillDate+timedelta(days=1)
fromDateTZ = fromDate.astimezone(pytz.timezone(tz))
fromDateTZ = fromDateTZ.astimezone(pytz.utc)
tillDateTZ = tillDate.astimezone(pytz.timezone(tz))
tillDateTZ = tillDateTZ.astimezone(pytz.utc)
fromDateString = fromDateTZ.strftime("%Y%m%d%H%M")
tillDateString = tillDateTZ.strftime("%Y%m%d%H%M")
url = "https://transparency.entsoe.eu/api?documentType=A44&in_Domain=10YNL----------L&out_Domain=10YNL----------L&periodStart="+fromDateString+"&periodEnd="+tillDateString+"&securityToken=" + entsoeKey
try:
prices = requests.get(url)
except Exception as e:
logger.error("Can't retrieve prices.")
return
try:
dict_data = xmltodict.parse(prices.content)
except Exception as e:
logger.exception(e)
logger.error("Can't retrieve prices.")
dict_data["Publication_MarketDocument"]["TimeSeries"]["Period"]["Point"]
dfPrices = pd.DataFrame.from_dict(dict_data["Publication_MarketDocument"]["TimeSeries"]["Period"]["Point"])
dfPrices['localDate'] = pd.to_datetime(datetime.now().replace(microsecond=0, second=0, minute=0, hour=0), utc=True) + dfPrices.position.astype('timedelta64[h]')-timedelta(hours=2)
dfPrices['localDate'] = dfPrices['localDate'].dt.tz_convert(tz)
dfPrices = dfPrices.astype({"price.amount": float})
dfPrices['price.amount'] = dfPrices['price.amount'].div(1000).round(2)
dfPrices.rename(columns={'price.amount': 'price'}, inplace=True)
dfPrices.drop('position', axis=1, inplace=True)
averagePrice = round(dfPrices["price"].mean()*100)/100
logger.info("Now prices retrieved for " + dayString)
logger.info("Average price is: €" + '%.2f' % averagePrice)
if PV:
url = "https://api.forecast.solar/estimate/"+str(locLat)+"/"+str(locLong)+"/"+str(angle)+"/"+str(direction)+"/"+str(totPower)+"?no_sun=1"
try:
pvInfo = requests.get(url)
except Exception as e:
logger.error("Can't retrieve PV info.")
return
try:
pvInfoDoc = pvInfo.json()
except Exception as e:
logger.error("Can't retrieve PV info.")
return
dfPVInfo = pd.DataFrame.from_dict(pvInfoDoc["result"]["watt_hours_period"],orient="index",columns=["wattHours"])
dfPVInfo.reset_index(inplace=True)
dfPVInfo.rename(columns={'index': 'date'}, inplace=True)
dfPVInfo['date'] = pd.to_datetime(dfPVInfo['date'])
dfPVInfo['date'] = dfPVInfo['date'].dt.tz_localize(tz)
tomorrowDT = datetime.now()+timedelta(days=1)
tomorrow = tomorrowDT.strftime("%Y-%m-%d")
dfPVInfo = dfPVInfo[(dfPVInfo['date'] < tomorrow)]
dfPrices = pd.merge(dfPrices, dfPVInfo, how='left', left_on="localDate", right_on="date")
dfPrices.drop(['date'], axis=1, inplace=True)
#Plot prices
if plotImage:
logger.info("Now Plotting")
x = range(24)
y = dfPrices.price.tolist()
fig, ax1 = plt.subplots()
ax1.set_ylabel('Price (€)')
colors = []
for i in range(len(y)):
if y[i] <= averagePrice*lowThreshold:
colors.append("red")
elif y[i] > averagePrice*highThreshold:
colors.append("green")
else:
colors.append("blue")
plot_1 = ax1.bar(x, y, color = colors)
ax1.tick_params(axis ='y', labelcolor = 'black')
plt.xlim(-1,24)
plt.grid(1)
if dateToday:
dayString = "today"
else:
dayString = "tomorrow"
if provider == 0:
providerString = "ANWB"
elif provider == 1:
providerString = "ENTSO-e"
timestr = time.strftime("%Y-%m-%d")
plt.title("Energy prices " + dayString + " " + timestr + ". Mean price: €" + '%.2f' % averagePrice + "\nEnergy provider: " +providerString)
ax1.axhline(y=averagePrice, color="green")
ax1.axhline(y=averagePrice*lowThreshold, color="red")
ax1.axhline(y=averagePrice*highThreshold, color="red")
# Create legend
handles, labels = plt.gca().get_legend_handles_labels()
patch = mpatches.Patch(color='red', label='Charge hours')
patch1 = mpatches.Patch(color='green', label='Sell hours')
patch2 = mpatches.Patch(color='blue', label='Normal ESS hours')
line = Line2D([0], [0], label='Average €' + '%.2f' % averagePrice, color='green')
lowThresholdPrice = averagePrice*lowThreshold
highThresholdPrice = averagePrice*highThreshold
line1 = Line2D([0], [0], label='Low threshold: €' + '%.2f' % lowThresholdPrice , color='red')
line2 = Line2D([0], [0], label='High threshold: €' + '%.2f' % highThresholdPrice , color='red')
handles.extend([patch, patch1, patch2, line, line1, line2])
ax1.set_xlabel('Hour')
if PV:
y2 = dfPrices.wattHours.tolist()
ax2 = ax1.twinx()
ax2.set_ylabel('Energy (Wh)')
plot_2 = ax2.plot(x, y2, color = 'yellow')
ax2.tick_params(axis ='y')
line3 = Line2D([0], [0], label='PV forecast: ' + str(round((dfPVInfo["wattHours"].sum()/1000)*100)/100) + " kWh", color='yellow')
handles.extend([line3])
ax1.legend(handles=handles, loc="best")
timestr = time.strftime("%Y%m%d")
fig.savefig("plot-" + timestr + ".png")
logger.info("New plot created and saved. Filename: " + "plot-" + timestr + ".png")
# Set charge and sell labels
if chargeMode == 1:
dfPrices['chargeCondition'] = np.where((dfPrices['price'] < averagePrice*lowThreshold), True, False)
if chargeMode == 2:
dfPrices['chargeCondition'] = np.where((dfPrices['price'] < averagePrice*lowThreshold), True, False)
dfPrices['dischargeCondition'] = np.where((dfPrices['price'] >= averagePrice*highThreshold), True, False)
if chargeMode == 3:
dfPrices.sort_values(by=['price'], inplace=True)
dfPrices = dfPrices.reset_index(drop=True)
dfPrices['sorting'] = dfPrices.index
dfPrices['chargeCondition'] = np.where((dfPrices['sorting'] < chargeHours), True, False)
def updateController():
global flagConntected, client, lastChargeCondition, lastDischargeCondition, lowThreshold, highThreshold
# Find current price and required charge condition
now = datetime.now().replace(microsecond=0, second=0, minute=0)
nowTZ = now.astimezone(pytz.timezone(tz))
chargePriceNow = dfPrices["price"].loc[dfPrices['localDate'] == nowTZ].item()
if chargeMode == 1 or 2:
chargeConditionNow = dfPrices["chargeCondition"].loc[dfPrices['localDate'] == nowTZ].item()
if chargeMode == 2:
dischargeConditionNow = dfPrices["dischargeCondition"].loc[dfPrices['localDate'] == nowTZ].item()
if chargeMode == 1 or 2 or 3:
if chargeConditionNow != lastChargeCondition:
logger.info("Requirement has changed, sending MQTT message to change setpoint.")
if chargeConditionNow:
# If the ESS should charge do this:
setChargeSetpoint()
logger.info("Current price is €" + '%.2f' % chargePriceNow + ". The average price today is €" + '%.2f' % averagePrice + ". This is lower than " + str(lowThreshold) + " * daily average so the battery is now charging.")
else:
# If the ESS should not charge do this:
setDefaultSetpoint()
logger.info("Current price is €" + '%.2f' % chargePriceNow + ". The average price today is €" + '%.2f' % averagePrice + ". Default setpoint is set. ")
else:
logger.info("Charge requirement has not changed. No MQTT message needed. ")
if chargeMode == 2:
if dischargeConditionNow != lastDischargeCondition:
logger.info("Requirement has changed, sending MQTT message to change setpoint.")
if dischargeConditionNow:
# If the ESS should discharge do this:
setDishargeSetpoint()
logger.info("Current price is €" + '%.2f' % chargePriceNow + ". The average price today is €" + '%.2f' % averagePrice + ". This is higher than " + str(highThreshold) + " * daily average so the battery is now discharging.")
else:
# If the ESS should not discharge do this:
setDefaultSetpoint()
logger.info("Current price is €" + '%.2f' % chargePriceNow + ". The average price today is €" + '%.2f' % averagePrice + ". Default setpoint is set. ")
else:
logger.info("Discharge requirement has not changed. No MQTT message needed. ")
# Set up scheduler
schedule.every().day.at("00:00:05").do(getPrices)
schedule.every(5).minutes.do(updateController)
logger.info("Script started.")
logger.info("Get prices is scheduled every day at 00:00:05.")
logger.info("The ESS controller is scheduled every 5 minutes.")
setDefaultSetpoint()
time.sleep(5)
getPrices()
updateController()
def main():
while True:
schedule.run_pending()
time.sleep(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print('Stop program')
except Exception as e:
print('Program crashed')
logger.exception(e)