diff --git a/README.md b/README.md index 6109f76f..0dd2bc3d 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,21 @@ This can be useful if you would like to track charger activity and use it for gr | [MySQL](docs/modules/Logging_MySQL.md) | Log data to a MySQL Database | | [SQLite](docs/modules/Logging_SQLite.md) | Log data to a SQLite Database | +### Pricing Interfaces + +| Platform | Details | +| --------------------------- | ----------------------- | +| [aWATTar](docs/modules/aWATTar_Pricing.md) | aWATTar dynamic pricing API | +| [Static](docs/modules/Static_Pricing.md) | Configure static pricing detail | + ### Status Interfaces Status interfaces publish TWCManager status information to external systems. Current Status interfaces are: -| Platform | Status | Details | -| ---------------- | ---------------- | ----------------------- | -| [HomeAssistant](docs/modules/Status_HASS.md) | Available v1.0.1 | Provides HASS sensors to monitor TWCManager State | -| [MQTT](docs/modules/Status_MQTT.md) | Available v1.0.1 | Publishes MQTT topics to monitor TWCManager State | +| Platform | Details | +| ---------------- | ----------------------- | +| [HomeAssistant](docs/modules/Status_HASS.md) | Provides HASS sensors to monitor TWCManager State | +| [MQTT](docs/modules/Status_MQTT.md) | Publishes MQTT topics to monitor TWCManager State | ### Vehicle Interfaces diff --git a/TWCManager.py b/TWCManager.py index 63b76607..6bd703aa 100755 --- a/TWCManager.py +++ b/TWCManager.py @@ -72,6 +72,9 @@ "EMS.TED", "Status.HASSStatus", "Status.MQTTStatus", + "Pricing.aWATTarPricing", + "Pricing.PVPCesPricing", + "Pricing.StaticPricing", ] # Enable support for Python Visual Studio Debugger @@ -223,6 +226,8 @@ def background_tasks_thread(master): check_green_energy() elif task["cmd"] == "getLifetimekWh": master.getSlaveLifetimekWh() + elif task["cmd"] == "getPricing": + master.getPricing() elif task["cmd"] == "getVehicleVIN": master.getVehicleVIN(task["slaveTWC"], task["vinPart"]) elif task["cmd"] == "snapHistoryData": diff --git a/docs/modules/Pricing_Static.md b/docs/modules/Pricing_Static.md new file mode 100644 index 00000000..703203c6 --- /dev/null +++ b/docs/modules/Pricing_Static.md @@ -0,0 +1,39 @@ +# Static Power Pricing Module + +## Introduction + +The Static Power Pricing Module allows configuration of power prices in environments where you do not have access to an API or dynamic source of pricing data. + +It currently only allows configuration of peak import and export rates. + +### Status + +| Detail | Value | +| --------------- | ------------------------------ | +| **Module Name** | Static | +| **Module Type** | Power Pricing Module (Pricing) | +| **Features** | Export, Import Pricing | +| **Status** | New, Work In Progress | + +## Configuration + +The following table shows the available configuration parameters for the Static Pricing module. + +| Parameter | Value | +| ------------- | ------------- | +| enabled | *required* Boolean value, ```true``` or ```false```. Determines whether we will use this pricing module. | +| peak > export | *optional* The export (sell to grid) price of power during the peak period | +| peak > import | *optional* The import (buy from grid) price of power during the peak period | + +### JSON Configuration Example + +``` +"pricing": { + "Static": { + "enabled": true, + "peak": { + "import": 0.10, + "export": 0.01 + } +} +``` diff --git a/docs/modules/Pricing_aWATTar.md b/docs/modules/Pricing_aWATTar.md new file mode 100644 index 00000000..92382da9 --- /dev/null +++ b/docs/modules/Pricing_aWATTar.md @@ -0,0 +1,35 @@ +# aWATTar Power Pricing Module + +## Introduction + +This module queries the Market Price data published by aWATTar for their energy customers. Please note that this is market pricing and not retail pricing, so whilst it provides insight into the price movements, it does not represent the exact cost to user. + +Currently, we only read the first price returned in the API call, which is the current market price. + +aWATTar's policy is to allow up to 100 queries per day of the data service. This equates to around 4 queries per hour or one every 15 minutes. We limit the query interval to this amount. + +### Status + +| Detail | Value | +| --------------- | ------------------------------ | +| **Module Name** | aWATTarPricing | +| **Module Type** | Power Pricing Module (Pricing) | +| **Features** | Import Pricing | +| **Status** | New | + +## Configuration + +The following table shows the available configuration parameters for the aWATTer pricing module. + +| Parameter | Value | +| ----------- | ------------- | +| enabled | *required* Boolean value, ```true``` or ```false```. Determines whether we will poll the aWATTar API for pricing. | + +### JSON Configuration Example + +``` +"pricing": { + "aWATTar": { + "enabled": true, +} +``` diff --git a/etc/twcmanager/config.json b/etc/twcmanager/config.json index 755f7969..e4b13a51 100644 --- a/etc/twcmanager/config.json +++ b/etc/twcmanager/config.json @@ -394,6 +394,43 @@ "path": "/etc/twcmanager/twcmanager.sqlite" } }, + "pricing":{ + "policy": { + # The multiPrice policy determines what we do when we have multiple + # pricing modules enabled. This might happen if your import and export + # pricing is tracked by different modules (for example, static export) + # and dynamic import). + # The default is to add all prices together. + # Other options are: + # - First: Takes the first non-zero value from the queried modules + "multiPrice": "add" + }, + "aWATTar": { + # Enable this module if you are a customer of aWATTar in Austria + "enabled": false + }, + "PVPCes": { + # Enable this module if you are a customer under PVPC in Spain + # You need to get a personal token from https://api.esios.ree.es/ + "enabled": false, + "token": "xxx" + }, + "Static": { + # The static pricing module allows you to specify static pricing, if + # your utility or provider do not provide a dynamic API. This still + # allows leveraging of pricing-related policy, even without an API. + + "enabled": false, + + # At this point, we only provide flat peak pricing, however future + # revisions should provide better tunability + # Pricing should be in units (dollar, euro, etc) per-kWh notation + "peak": { + "import": 0.20, + "export": 0.09 + } + } + }, "sources":{ # This section is where we configure the various sources that we retrieve our generation and consumption # values for our solar system from. diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 3568acba..416703b6 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -192,6 +192,17 @@ def do_API_GET(self): ) self.wfile.write(json_data.encode("utf-8")) + elif self.url.path == "/api/getPricing": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + json_data = json.dumps({ + "export": master.getExportPrice(), + "import": master.getImportPrice() + }) + self.wfile.write(json_data.encode("utf-8")) + elif self.url.path == "/api/getSlaveTWCs": data = {} totals = { diff --git a/lib/TWCManager/Policy/Policy.py b/lib/TWCManager/Policy/Policy.py index 19089106..5a6e16b8 100644 --- a/lib/TWCManager/Policy/Policy.py +++ b/lib/TWCManager/Policy/Policy.py @@ -229,6 +229,10 @@ def enforcePolicy(self, policy, updateLatch=False): limit = -1 self.master.queue_background_task({"cmd": "applyChargeLimit", "limit": limit}) + # If at least one pricing module is active, fetch current pricing + if len(self.master.getModulesByType("Pricing")) > 0: + self.master.queue_background_task({"cmd": "getPricing"}) + def fireWebhook(self, hook): policy = self.getPolicyByName(self.active_policy) if policy: diff --git a/lib/TWCManager/Pricing/PVPCesPricing.py b/lib/TWCManager/Pricing/PVPCesPricing.py new file mode 100644 index 00000000..7b02865b --- /dev/null +++ b/lib/TWCManager/Pricing/PVPCesPricing.py @@ -0,0 +1,192 @@ +from datetime import datetime +from datetime import timedelta + +class PVPCesPricing: + + import requests + import time + + # https://www.esios.ree.es/es/pvpc publishes at 20:30CET eveyday the prices for next day + # There is no limitation to fetch prices as it's updated onces a day + cacheTime = 1 + capabilities = { + "AdvancePricing": True + } + config = None + configConfig = None + configPvpc = None + exportPrice = 0 + fetchFailed = False + importPrice = 0 + lastFetch = 0 + status = False + timeout = 10 + headers = {} + todayImportPrice = {} + + def __init__(self, master): + + self.master = master + self.config = master.config + try: + self.configConfig = master.config["config"] + except KeyError: + self.configConfig = {} + + try: + self.configPvpc = master.config["pricing"]["PVPCes"] + except KeyError: + self.configPvpc = {} + + self.status = self.configPvpc.get("enabled", self.status) + self.debugLevel = self.configConfig.get("debugLevel", 0) + + token=self.configPvpc.get("token") + if self.status: + self.headers = { + 'Accept': 'application/json; application/vnd.esios-api-v1+json', + 'Content-Type': 'application/json', + 'Host': 'api.esios.ree.es', + 'Cookie': '', + } + self.headers['Authorization']="Token token="+token + + # Unload if this module is disabled or misconfigured + if not self.status: + self.master.releaseModule("lib.TWCManager.Pricing", self.__class__.__name__) + return None + + def getCapabilities(self, capability): + # Allows query of module capabilities + return self.capabilities.get(capability, False) + + def getExportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$PVPCes", + "PVPCes Pricing Module Disabled. Skipping getExportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + # Return current export price + return float(self.exportPrice) + + def getImportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$PVPCes", + "PVPCes Pricing Module Disabled. Skipping getImportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + + + # Return current import price + return float(self.importPrice) + + def update(self): + + # Fetch the current pricing data from the https://www.esios.ree.es/es/pvpc API + self.fetchFailed = False + now=datetime.now() + tomorrow=datetime.now() + timedelta(days=1) + if self.lastFetch == 0 or (now.hour < self.lastFetch.hour): + # Cache not feched or was feched yesterday. Fetch values from API. + ini=str(now.year)+"-"+str(now.month)+"-"+str(now.day)+"T"+"00:00:00" + end=str(tomorrow.year)+"-"+str(tomorrow.month)+"-"+str(tomorrow.day)+"T"+"23:00:00" + + url = "https://api.esios.ree.es/indicators/1014?start_date="+ini+"&end_date="+end + + try: + r = self.requests.get(url,headers=self.headers, timeout=self.timeout) + except self.requests.exceptions.ConnectionError as e: + self.master.debugLog( + 4, + "$PVPCes", + "Error connecting to PVPCes API to fetch market pricing", + ) + self.fetchFailed = True + return False + + self.lastFetch= now + + try: + r.raise_for_status() + except self.requests.exceptions.HTTPError as e: + self.master.debugLog( + 4, + "$PVPCes", + "HTTP status " + + str(e.response.status_code) + + " connecting to PVPCes API to fetch market pricing", + ) + return False + + if r.json(): + self.todayImportPrice=r.json() + + if self.todayImportPrice: + try: + self.importPrice = float( + self.todayImportPrice['indicator']['values'][now.hour]['value'] + ) + # Convert MWh price to KWh + self.importPrice = round(self.importPrice / 1000,5) + + except (KeyError, TypeError) as e: + self.master.debugLog( + 4, + "$PVPCes", + "Exception during parsing PVPCes pricing", + ) + + def getCheapestStartHour(self,numHours,ini,end): + # Perform updates if necessary + self.update() + + minPriceHstart=ini + if self.todayImportPrice: + try: + if end < ini: + # If the scheduled hours are bettween days we consider hours going from 0 to 47 + # tomorrow 1am will be 25 + end = 24 + end + + i=ini + minPrice=999999999 + while i<=(end-numHours): + j=0 + priceH=0 + while j 23: + minPriceHstart = minPriceHstart - 24 + + return minPriceHstart diff --git a/lib/TWCManager/Pricing/StaticPricing.py b/lib/TWCManager/Pricing/StaticPricing.py new file mode 100644 index 00000000..a5f1c510 --- /dev/null +++ b/lib/TWCManager/Pricing/StaticPricing.py @@ -0,0 +1,82 @@ +class StaticPricing: + + # For environments where dynamic pricing information is not available via an + # API, the pricing information can be configured statically within the config + # file + + import time + + capabilities = { + "AdvancePricing": True + } + config = None + configConfig = None + configStatic = None + exportPrice = 0 + importPrice = 0 + status = False + + def __init__(self, master): + + self.master = master + self.config = master.config + try: + self.configConfig = master.config["config"] + except KeyError: + self.configConfig = {} + + try: + self.configStatic = master.config["pricing"]["Static"] + except KeyError: + self.configStatic = {} + + self.status = self.configStatic.get("enabled", self.status) + self.debugLevel = self.configConfig.get("debugLevel", 0) + + # Unload if this module is disabled or misconfigured + if not self.status: + self.master.releaseModule("lib.TWCManager.Pricing", self.__class__.__name__) + return None + + def getCapabilities(self, capability): + # Allows query of module capabilities + return self.capabilities.get(capability, False) + + def getExportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$Static", + "Static Pricing Module Disabled. Skipping getExportPrice", + ) + return 0 + + # For now, we just use the peak price + try: + self.exportPrice = self.configStatic["peak"]["export"] + except ValueError: + self.exportPrice = 0 + + # Return current export price + return float(self.exportPrice) + + def getImportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$Static", + "Static Pricing Module Disabled. Skipping getImportPrice", + ) + return 0 + + # For now, we just use the peak price + try: + self.importPrice = self.configStatic["peak"]["import"] + except ValueError: + self.exportPrice = 0 + + # Return current import price + return float(self.importPrice) + diff --git a/lib/TWCManager/Pricing/aWATTarPricing.py b/lib/TWCManager/Pricing/aWATTarPricing.py new file mode 100644 index 00000000..bbcd606c --- /dev/null +++ b/lib/TWCManager/Pricing/aWATTarPricing.py @@ -0,0 +1,128 @@ +class aWATTarPricing: + + import requests + import time + + # aWATTar asks us to limit queries to once every 15 minutes + cacheTime = 900 + capabilities = { + "AdvancePricing": True + } + config = None + configConfig = None + configAwattar = None + exportPrice = 0 + fetchFailed = False + importPrice = 0 + lastFetch = 0 + status = False + timeout = 10 + + def __init__(self, master): + + self.master = master + self.config = master.config + try: + self.configConfig = master.config["config"] + except KeyError: + self.configConfig = {} + + try: + self.configAwattar = master.config["pricing"]["aWATTar"] + except KeyError: + self.configAwattar = {} + + self.status = self.configAwattar.get("enabled", self.status) + self.debugLevel = self.configConfig.get("debugLevel", 0) + + # Unload if this module is disabled or misconfigured + if not self.status: + self.master.releaseModule("lib.TWCManager.Pricing", self.__class__.__name__) + return None + + def getCapabilities(self, capability): + # Allows query of module capabilities + return self.capabilities.get(capability, False) + + def getExportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$aWATTar", + "aWATTar Pricing Module Disabled. Skipping getExportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + # Return current export price + return float(self.exportPrice) + + def getImportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$aWATTar", + "aWATTar Pricing Module Disabled. Skipping getImportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + # Return current import price + return float(self.importPrice) + + def update(self): + + # Fetch the current pricing data from the Awattar API + self.fetchFailed = False + + if (int(self.time.time()) - self.lastFetch) > self.cacheTime: + # Cache has expired. Fetch values from API. + + url = "https://api.awattar.at/v1/marketdata" + + try: + r = self.requests.get(url, timeout=self.timeout) + except self.requests.exceptions.ConnectionError as e: + self.master.debugLog( + 4, + "$aWATTar", + "Error connecting to aWATTar API to fetch market pricing", + ) + self.master.debugLog(10, "$aWATTar", str(e)) + self.fetchFailed = True + return False + + try: + r.raise_for_status() + except self.requests.exceptions.HTTPError as e: + self.master.debugLog( + 4, + "$aWATTar", + "HTTP status " + + str(e.response.status_code) + + " connecting to aWATTar API to fetch market pricing", + ) + + if r.json(): + try: + self.importPrice = float( + r.json()["data"][0]["marketprice"] + ) + if r.json()["data"][0]["unit"] == "Eur/MWh": + # Convert MWh price to KWh + self.importPrice = self.importPrice / 1000 + + except (KeyError, TypeError) as e: + self.master.debugLog( + 4, + "$aWATTar", + "Exception during parsing aWATTar pricing", + ) + + diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index a2c7cbb0..deb7a8e2 100644 --- a/lib/TWCManager/TWCMaster.py +++ b/lib/TWCManager/TWCMaster.py @@ -26,7 +26,9 @@ class TWCMaster: consumptionValues = {} debugLevel = 0 debugOutputToFile = False + exportPricingValues = {} generationValues = {} + importPricingValues = {} lastkWhMessage = time.time() lastkWhPoll = 0 lastTWCResponseMsg = None @@ -230,14 +232,17 @@ def getChargeNowAmps(self): def getHourResumeTrackGreenEnergy(self): return self.settings.get("hourResumeTrackGreenEnergy", -1) + def getInterfaceModule(self): + return self.getModulesByType("Interface")[0]["ref"] + + def getkWhDelivered(self): + return self.settings["kWhDelivered"] + def getMasterTWCID(self): # This is called when TWCManager is in Slave mode, to track the # master's TWCID return self.masterTWCID - def getkWhDelivered(self): - return self.settings["kWhDelivered"] - def getMaxAmpsToDivideAmongSlaves(self): if self.maxAmpsToDivideAmongSlaves > 0: return self.maxAmpsToDivideAmongSlaves @@ -259,8 +264,10 @@ def getModulesByType(self, type): matched.append({"name": module, "ref": modinfo["ref"]}) return matched - def getInterfaceModule(self): - return self.getModulesByType("Interface")[0]["ref"] + def getPricing(self): + for module in self.getModulesByType("Pricing"): + self.exportPricingValues[module["name"]] = module["ref"].getExportPrice() + self.importPricingValues[module["name"]] = module["ref"].getImportPrice() def getScheduledAmpsDaysBitmap(self): return self.settings.get("scheduledAmpsDaysBitmap", 0x7F) @@ -466,6 +473,28 @@ def getConsumption(self): return float(consumptionVal) + def getExportPrice(self): + price = 0 + multiPrice = "" + + # The policy for how we should deal with multiple export + # prices (multiple concurrent modules) is defined in the config + # file in config->pricing->policy->multiPrice + try: + multiPrice = self.config["config"]["pricing"]["policy"]["multiPrice"] + except KeyError: + multiPrice = "add" + + # Iterate through values and apply multiPrice policy + for key in self.exportPricingValues: + if multiPrice == "add": + price += float(self.exportPricingValues[key]) + elif multiPrice == "first": + if price == 0 and self.exportPricingValues[key] > 0: + price = float(self.exportPricingValues[key]) + + return float(price) + def getFakeTWCID(self): return self.TWCID @@ -500,6 +529,28 @@ def getHomeLatLon(self): latlon[1] = self.settings.get("homeLon", 10000) return latlon + def getImportPrice(self): + price = 0 + multiPrice = "" + + # The policy for how we should deal with multiple import + # prices (multiple concurrent modules) is defined in the config + # file in config->pricing->policy->multiPrice + try: + multiPrice = self.config["config"]["pricing"]["policy"]["multiPrice"] + except KeyError: + multiPrice = "add" + + # Iterate through values and apply multiPrice policy + for key in self.importPricingValues: + if multiPrice == "add": + price += float(self.importPricingValues[key]) + elif multiPrice == "first": + if price == 0 and self.importPricingValues[key] > 0: + price = float(self.importPricingValues[key]) + + return float(price) + def getMasterHeartbeatOverride(self): return self.overrideMasterHeartbeatData