Skip to content

Commit

Permalink
added spot price scraping, code cleanups
Browse files Browse the repository at this point in the history
  • Loading branch information
gustonator committed Mar 24, 2023
1 parent 05ccb10 commit 00d180c
Show file tree
Hide file tree
Showing 4 changed files with 97 additions and 31 deletions.
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
FROM python:3.8-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY /src/exporter.py .
RUN pip install --upgrade goodwe asyncio prometheus_client
RUN pip install --upgrade goodwe asyncio aiohttp prometheus_client
ENTRYPOINT ["python", "exporter.py"]
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ pip install goodwe==0.2.23

### Run/test

3. to test, start the exporter:
3. to test, start the exporter with minimal configuration:
```
python exporter.py --port <desired port> --interval <interval (s)> --inverter <inverterIP>
ie.
python exporter.py --port 8787 --interval 30 --inverter 192.168.2.35
```
(for more settings, see parameters below)
</br>

now you can call it via curl to see,if it exports some metrics:
Expand Down Expand Up @@ -86,7 +87,9 @@ curl http://<IP>:8787

### Supported parameters

`--inverter <inverterIP>` - [required] IP address of the iverter. To get the IP Address, you can run the `inverter_scan.py` script. </br>
`--inverter <inverterIP>` - [required] IP address of the iverter. To get the IP Address, you can run the 'inverter_scan.py' script. </br>
`--port <desired port>` - [optional][default: 8787] port, on which the exporter should expose the metrics</br>
`--interval <interval (s)>` - [optional][default: 30] interval between scrapings in seconds.</br>
`--energy-price <value>` - [optional][default: 0.15] energy price per kwh (in eur )</br>
`--energy-price <value>` - [optional][default: 0.15] energy price per kwh (in eur). If '--scrape-spot-price' is set to true, '--energy-price' value is ignored</br>
`--PVpower <value>` - [optional][default: 5670] maximum power in Watts of your PV you can generate (ie. 5670 = 5670W)</br>
`--scrape-spot-price <bool>` - [optional][default: False] True/False, if the exporter should scrape spot price from https://www.ote-cr.cz. If it's set to 'True', exporter will set spot_price as the energy price</br>
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ services:
- "--port=8787"
- "--interval=30"
- "--inverter=192.168.1.10"
- "--energy-price=0.15"
- "--PVpower=5.67"
- "--scrape-spot-price=False"
networks:
- internal
restart: unless-stopped
Expand Down
113 changes: 86 additions & 27 deletions src/exporter.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
from prometheus_client import CollectorRegistry, Gauge, Counter, Info
from datetime import datetime
from datetime import date, datetime
from decimal import Decimal
import prometheus_client as prometheus
import xml.etree.ElementTree as ET
import logging
import sys
import getopt
import time
import socket
import urllib.request
import asyncio
import aiohttp
import goodwe

print("\nGOODWE DATA EXPORTER v1.2.0\n")
#logger = logging.getLogger(__name__)

print("\nGOODWE DATA EXPORTER v1.3.0\n")

QUERY = '''<?xml version="1.0" encoding="UTF-8" ?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:pub="http://www.ote-cr.cz/schema/service/public">
<soapenv:Header/>
<soapenv:Body>
<pub:GetDamPriceE>
<pub:StartDate>{start}</pub:StartDate>
<pub:EndDate>{end}</pub:EndDate>
<pub:InEur>{in_eur}</pub:InEur>
</pub:GetDamPriceE>
</soapenv:Body>
</soapenv:Envelope>
'''
class OTEFault(Exception):
pass

class InvalidFormat(OTEFault):
pass


def checkArgs(argv):
Expand All @@ -18,19 +40,21 @@ def checkArgs(argv):
global INVERTER_IP
global ENERGY_PRICE
global PV_POWER
global SCRAPE_SPOT_PRICE

# set default values
EXPORTER_PORT = 8787
POLLING_INTERVAL = 30
ENERGY_PRICE = 0.15
ENERGY_PRICE = 0
PV_POWER = 5670
INVERTER_IP = ""
SCRAPE_SPOT_PRICE = False

# help
arg_help = "{0} --port <exporter port [default:8787]> --interval <scrape interval (seconds) [default:30]> --inverter <inverter IP> --energy-price <price per KWh in eur [default: 0.15]> --PVpower <maximum KW your PV can produce [default:5670]".format(argv[0])
arg_help = "{0} --port <exporter port [default:8787]> --interval <scrape interval (seconds) [default:30]> --inverter <inverter IP> --energy-price <price per KWh in eur [default: 0]> --PVpower <maximum KW your PV can produce [default:5670] --scrape-spot-price <True/False> [default: False] ".format(argv[0])

try:
opts, args = getopt.getopt(argv[1:], "hp:t:i:", ["help", "port=", "interval=", "inverter=", "energy-price=", "PVpower="])
opts, args = getopt.getopt(argv[1:], "hp:t:i:s:", ["help", "port=", "interval=", "inverter=", "energy-price=", "PVpower=", "scrape-spot-price="])
except:
print(arg_help)
sys.exit(2)
Expand All @@ -40,7 +64,7 @@ def checkArgs(argv):
print(arg_help)
sys.exit(2)
elif opt in ("-p", "--port"):
EXPORTER_PORT= arg
EXPORTER_PORT= arg
elif opt in ("-t", "--interval"):
POLLING_INTERVAL = arg
elif opt in ("-i", "--inverter"):
Expand All @@ -49,23 +73,51 @@ def checkArgs(argv):
ENERGY_PRICE = arg
elif opt in ("-w", "--PVpower"):
PV_POWER = arg

elif opt in ("-s", "--scrape-spot-price"):
if arg.lower() == 'false':
SCRAPE_SPOT_PRICE = False
else:
SCRAPE_SPOT_PRICE = True

# check if Inverter IP is set
if not INVERTER_IP:
print("ERROR: missing IP Address of inverter!")
exit(1)


print("ERROR: missing IP Address of inverter!\n")
print(arg_help)
sys.exit(2)

class InverterMetrics:
def __init__(self, g, i, POLLING_INTERVAL,ENERGY_PRICE,PV_POWER):
ELECTRICITY_PRICE_URL = 'https://www.ote-cr.cz/services/PublicDataService'

# build the query - fill the variables
def get_query(self, start: date, end: date, in_eur: bool) -> str:
return QUERY.format(start=start.isoformat(), end=end.isoformat(), in_eur='true' if in_eur else 'false')

# download data from web
async def _download(self, query: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.post(self.ELECTRICITY_PRICE_URL, data=query) as response:
return await response.text()

def parse_spot_data(self, xmlResponse):
root = ET.fromstring(xmlResponse)
for item in root.findall('.//{http://www.ote-cr.cz/schema/service/public}Item'):
hour_el = item.find('{http://www.ote-cr.cz/schema/service/public}Hour')
price_el = item.find('{http://www.ote-cr.cz/schema/service/public}Price')
current_hour = datetime.now().hour

if (int(hour_el.text) - 1) == current_hour:
price_el = Decimal(price_el.text)
price_el /= Decimal(1000) #convert MWh -> KWh
return price_el

def __init__(self, POLLING_INTERVAL,ENERGY_PRICE,PV_POWER,SCRAPE_SPOT_PRICE):
self.POLLING_INTERVAL = POLLING_INTERVAL
self.ENERGY_PRICE = ENERGY_PRICE
self.PV_POWER = PV_POWER
self.SCRAPE_SPOT_PRICE = SCRAPE_SPOT_PRICE
self.metricsCount = 0
self.g = g
self.i = i
self.g = []
self.i = []

# create placeholder for metrics in the register
def collector_register(self):
Expand All @@ -85,21 +137,26 @@ async def create_collector_registers():

# add additional PV Power
self.g.append(Gauge("pv_total_power", "Total power in WATTS, that can be produced by PV"))

asyncio.run(create_collector_registers())


# scrape loop
def run_metrics_loop(self):
self.collector_register()
while True:
self.fetch_data()
time.sleep(self.POLLING_INTERVAL)


# scrape metrics in a loop and write to the prepared metrics register
def fetch_data(self):
self.metricsCount = 0

# get spot prices
if self.SCRAPE_SPOT_PRICE:
query = self.get_query(date.today(), date.today(), in_eur=True)
xmlResponse = asyncio.run(self._download(query))
self.ENERGY_PRICE = self.parse_spot_data(xmlResponse)

async def fetch_inverter():
inverter = await goodwe.connect(INVERTER_IP)
runtime_data = await inverter.read_runtime_data()
Expand All @@ -111,33 +168,35 @@ async def fetch_inverter():
countID+=1

# set value for additional energy-price
self.g[countID].set(float(ENERGY_PRICE))
self.g[countID].set(float(self.ENERGY_PRICE))
self.g[countID+1].set(float(PV_POWER))
self.metricsCount=len(self.g)

asyncio.run(fetch_inverter())

# print number of metrics and date and rewrites it every time
print('-------------------------------------------------------')
if self.SCRAPE_SPOT_PRICE:
print("energy price(spot):\t\t"+str(self.ENERGY_PRICE)+" eur/KW")
else:
print("energy price (fixed):\t\t"+str(self.ENERGY_PRICE)+" eur/KW")
print("number of metrics:\t\t"+str(self.metricsCount))
print("last scrape:\t\t\t"+ str(datetime.now().strftime("%d.%m.%Y %H:%M:%S")), end='\r')
print('\033[1A', end='\x1b[2K')

print("last scrape:\t\t\t"+ str(datetime.now().strftime("%d.%m.%Y %H:%M:%S")))


def main():
checkArgs(sys.argv)

print("polling interval:\t\t"+str(POLLING_INTERVAL)+"s")
print("inverter scrape IP:\t\t"+str(INVERTER_IP))
print("energy price: \t\t\t"+str(ENERGY_PRICE)+"eur")
print("total PV power: \t\t\t"+str(PV_POWER)+"W")
print("fixed energy price: \t\t"+str(ENERGY_PRICE)+" eur/KW")
print("total PV power: \t\t"+str(PV_POWER)+"W")

inverter_metrics = InverterMetrics(
POLLING_INTERVAL=int(POLLING_INTERVAL),
ENERGY_PRICE=ENERGY_PRICE,
PV_POWER=PV_POWER,
g=[],
i=[]
SCRAPE_SPOT_PRICE=SCRAPE_SPOT_PRICE
)

# Start the server to expose metrics.
Expand Down

0 comments on commit 00d180c

Please sign in to comment.