From 089f9f2b32d181dd33c4e1e104f85cf7590248d7 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:14:49 +0200 Subject: [PATCH 01/24] Refresh of calculation methods --- custom_components/entsoe/const.py | 11 ++++-- custom_components/entsoe/coordinator.py | 50 +++++++++++++++++-------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index 99f1e4c..7e43c25 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -21,10 +21,13 @@ # default is only for internal use / backwards compatibility CALCULATION_MODE = { - "default": "publish", - "rotation": "rotation", - "sliding": "sliding", - "publish": "publish", + "default": "publish", + "publish": "publish", + "daily": "daily", + "sliding": "sliding", + "sliding-12": "sliding-12", # new half day sliding + "forecast": "forecast", # 24hrs forward looking + "forecast-12": "forecast-12", # 12hrs forward looking } ENERGY_SCALES = { "kWh": 1000, "MWh": 1 } diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 6d2a79a..0806b20 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -121,7 +121,8 @@ async def _async_update_data(self) -> dict: self.data = parsed_data self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) return parsed_data - + + # fetching of new data is needed when (1) we have no data, (2) when todays data is below 20 hrs or (3) tomorrows data is below 20hrs and its after 11 def check_update_needed(self, now): if self.data is None: return True @@ -178,6 +179,7 @@ async def get_energy_prices(self, start_date, end_date): } return self.parse_hourprices(await self.fetch_prices(start_date, end_date)) + # TODO: this method is called by each sensor, each hour. Change the code so only the first will update def update_data(self): now = dt.now() if self.today.date() != now.date(): @@ -190,27 +192,43 @@ def today_data_available(self): return len(self.get_data_today()) > MIN_HOURS def _filter_calculated_hourprices(self, data): - # rotation = calculations made upon 24hrs today - if self.calculation_mode == CALCULATION_MODE["rotation"]: + if self.calculation_mode == CALCULATION_MODE["daily"]: + self.logger.debug(f"Filter dataset for prices today -> refresh each day") return { hour: price for hour, price in data.items() if hour >= self.today and hour < self.today + timedelta(days=1) } - # sliding = calculations made on all data from the current hour and beyond (future data only) + elif self.calculation_mode == CALCULATION_MODE["sliding"]: - now = dt.now().replace(minute=0, second=0, microsecond=0) - return {hour: price for hour, price in data.items() if hour >= now} - # publish >48 hrs of data = calculations made on all data of today and tomorrow (48 hrs) - elif self.calculation_mode == CALCULATION_MODE["publish"] and len(data) > 48: - return {hour: price for hour, price in data.items() if hour >= self.today} - # publish <=48 hrs of data = calculations made on all data of yesterday and today (48 hrs) - elif self.calculation_mode == CALCULATION_MODE["publish"]: - return { - hour: price - for hour, price in data.items() - if hour >= self.today - timedelta(days=1) - } + start = dt.now().replace(minute=0, second=0, microsecond=0) + start -= timedelta(hours=12) + end = start + timedelta(hours=24) + self.logger.debug(f"Filter dataset to surrounding 24hrs {start} - {end} -> refresh each hour") + return {hour: price for hour, price in data.items() if start < hour < end } + + elif self.calculation_mode == CALCULATION_MODE["sliding-12"]: + start = dt.now().replace(minute=0, second=0, microsecond=0) + start -= timedelta(hours=6) + end = start + timedelta(hours=12) + self.logger.debug(f"Filter dataset to surrounding 12hrs {start} - {end} -> refresh each hour") + return {hour: price for hour, price in data.items() if start < hour < end } + + elif self.calculation_mode == CALCULATION_MODE["forecast"]: + start = dt.now().replace(minute=0, second=0, microsecond=0) + end = start + timedelta(hours=24) + self.logger.debug(f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour") + return {hour: price for hour, price in data.items() if start < hour < end } + + elif self.calculation_mode == CALCULATION_MODE["forecast-12"]: + start = dt.now().replace(minute=0, second=0, microsecond=0) + end = start + timedelta(hours=12) + self.logger.debug(f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour") + return {hour: price for hour, price in data.items() if start < hour < end } + + # default elif self.calculation_mode == CALCULATION_MODE["publish"]: + self.logger.debug(f"Do not filter the dataset, use the complete dataset as fetched") + return { hour: price for hour, price in data.items() } def get_prices_today(self): return self.get_timestamped_prices(self.get_data_today()) From fd9156b4a0ac494da44ec5e03b12f8056fe9f89c Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:34:25 +0200 Subject: [PATCH 02/24] Some small adjustements --- custom_components/entsoe/const.py | 4 ++-- custom_components/entsoe/coordinator.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index 7e43c25..ccfbb7b 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -26,8 +26,8 @@ "daily": "daily", "sliding": "sliding", "sliding-12": "sliding-12", # new half day sliding - "forecast": "forecast", # 24hrs forward looking - "forecast-12": "forecast-12", # 12hrs forward looking + "forward": "forward", # 24hrs forward looking + "forward-12": "forward-12", # 12hrs forward looking } ENERGY_SCALES = { "kWh": 1000, "MWh": 1 } diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 0806b20..9e7d349 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -214,16 +214,16 @@ def _filter_calculated_hourprices(self, data): self.logger.debug(f"Filter dataset to surrounding 12hrs {start} - {end} -> refresh each hour") return {hour: price for hour, price in data.items() if start < hour < end } - elif self.calculation_mode == CALCULATION_MODE["forecast"]: + elif self.calculation_mode == CALCULATION_MODE["forward"]: start = dt.now().replace(minute=0, second=0, microsecond=0) end = start + timedelta(hours=24) self.logger.debug(f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour") return {hour: price for hour, price in data.items() if start < hour < end } - elif self.calculation_mode == CALCULATION_MODE["forecast-12"]: + elif self.calculation_mode == CALCULATION_MODE["forward-12"]: start = dt.now().replace(minute=0, second=0, microsecond=0) end = start + timedelta(hours=12) - self.logger.debug(f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour") + self.logger.debug(f"Filter dataset to upcomming 12hrs {start} - {end} -> refresh each hour") return {hour: price for hour, price in data.items() if start < hour < end } # default elif self.calculation_mode == CALCULATION_MODE["publish"]: From 0d07dcc8a246a7d658085ea1d9c4c48c47f1bfd8 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:38:12 +0200 Subject: [PATCH 03/24] Some Todo's added --- custom_components/entsoe/coordinator.py | 188 +++++++++++++----------- 1 file changed, 105 insertions(+), 83 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 6d464c6..1665672 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -17,7 +17,9 @@ # depending on timezone les than 24 hours could be returned. MIN_HOURS = 20 - +# This class contains actually two main tasks +# 1. ENTSO: Refresh data from ENTSO on interval basis triggered by HASS every 60 minutes +# 2. ANALYSIS: Implement some analysis on this data, like min(), max(), avg(), perc(). Updated analysis is triggered by an explicit call from a sensor class EntsoeCoordinator(DataUpdateCoordinator): """Get the latest data and update the states.""" @@ -62,7 +64,7 @@ def __init__( update_interval=timedelta(minutes=60), ) - # calculate the price using the given template + # ENTSO: recalculate the price using the given template def calc_price(self, value, fake_dt=None, no_template=False) -> float: """Calculate price based on the users settings.""" # Used to inject the current hour. @@ -90,12 +92,13 @@ def inner(*args, **kwargs): return price + # ENTSO: recalculate the price for each price def parse_hourprices(self, hourprices): for hour, price in hourprices.items(): hourprices[hour] = self.calc_price(value=price, fake_dt=hour) return hourprices - # Called by HA every refresh interval (60 minutes) + # ENTSO: Triggered by HA to refresh the data (interval = 60 minutes) async def _async_update_data(self) -> dict: """Get the latest data from ENTSO-e""" self.logger.debug("ENTSO-e DataUpdateCoordinator data update") @@ -125,6 +128,7 @@ async def _async_update_data(self) -> dict: self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) return parsed_data + # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs left for today, or less than 20hrs tomorrow and its after 11 def check_update_needed(self, now): if self.data is None: return True @@ -134,6 +138,7 @@ def check_update_needed(self, now): return True return False + # ENTSO: new prices using an async job async def fetch_prices(self, start_date, end_date): try: # run api_update in async job @@ -161,49 +166,89 @@ async def fetch_prices(self, start_date, end_date): f"Warning the integration doesn't have any up to date local data this means that entities won't get updated but access remains to restorable entities: {exc}." ) + # ENTSO: the async fetch job itself def api_update(self, start_date, end_date, api_key): client = EntsoeClient(api_key=api_key) return client.query_day_ahead_prices( country_code=self.area, start=start_date, end=end_date ) + + # ENTSO: Return the data for the given date + def get_data(self, date): + return {k: v for k, v in self.data.items() if k.date() == date.date()} - async def get_energy_prices(self, start_date, end_date): - # check if we have the data already - if ( - len(self.get_data(start_date)) > MIN_HOURS - and len(self.get_data(end_date)) > MIN_HOURS - ): - self.logger.debug(f"return prices from coordinator cache.") - return { - k: v - for k, v in self.data.items() - if k.date() >= start_date.date() and k.date() <= end_date.date() - } - return self.parse_hourprices(await self.fetch_prices(start_date, end_date)) + # ENTSO: Return the data for today + def get_data_today(self): + return self.get_data(self.today) + + # ENTSO: Return the data for tomorrow + def get_data_tomorrow(self): + return self.get_data(self.today + timedelta(days=1)) + + # ENTSO: Return the data for yesterday + def get_data_yesterday(self): + return self.get_data(self.today - timedelta(days=1)) + # SENSOR: Do we have data available for today TODO: remove def today_data_available(self): return len(self.get_data_today()) > MIN_HOURS + + # SENSOR: Get the current price + def get_current_hourprice(self) -> int: + return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] + + # SENSOR: Get the next hour price + def get_next_hourprice(self) -> int: + return self.data[ + dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + ] + + # SENSOR: Get timestamped prices of today as attribute for Average Sensor + def get_prices_today(self): + return self.get_timestamped_prices(self.get_data_today()) - # this method is called by each sensor, each complete hour, and ensures the date and filtered hourprices are in line with the current time + # SENSOR: Get timestamped prices of tomorrow as attribute for Average Sensor + def get_prices_tomorrow(self): + return self.get_timestamped_prices(self.get_data_tomorrow()) + + # SENSOR: Get timestamped prices of today & tomorrow or yesterday & today as attribute for Average Sensor + # TODO: why is there another logic when data is below 48 hrs and does this ever happen? + def get_prices(self): + if len(self.data) > 48: + return self.get_timestamped_prices( + {hour: price for hour, price in self.data.items() if hour >= self.today} + ) + return self.get_timestamped_prices( + { + hour: price + for hour, price in self.data.items() + if hour >= self.today - timedelta(days=1) + } + ) + + # SENSOR: Timestamp the prices + def get_timestamped_prices(self, hourprices): + list = [] + for hour, price in hourprices.items(): + str_hour = str(hour) + list.append({"time": str_hour, "price": price}) + return list + + # -------------------------------------------------------------------------------------------------------------------------------- + # ANALYSIS: this method is called by each sensor, each complete hour, and ensures the date and filtered hourprices are in line with the current time # we could still optimize as not every calculator mode needs hourly updates def sync_calculator(self): now = dt.now() - if ( - self.calculator_last_sync is None - or self.calculator_last_sync.hour != now.hour - ): - self.logger.debug( - f"The calculator needs to be synced with the current time" - ) + if self.calculator_last_sync is None or self.calculator_last_sync.hour != now.hour: + self.logger.debug(f"The calculator needs to be synced with the current time") if self.today.date() != now.date(): - self.logger.debug( - f"new day detected: update today and filtered hourprices" - ) + self.logger.debug(f"new day detected: update today and filtered hourprices") self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) self.filtered_hourprices = self._filter_calculated_hourprices(self.data) - self.calculator_last_sync = now + self.calculator_last_sync = now + # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode def _filter_calculated_hourprices(self, data): # rotation = calculations made upon 24hrs today if self.calculation_mode == CALCULATION_MODE["rotation"]: @@ -219,7 +264,7 @@ def _filter_calculated_hourprices(self, data): # publish >48 hrs of data = calculations made on all data of today and tomorrow (48 hrs) elif self.calculation_mode == CALCULATION_MODE["publish"] and len(data) > 48: return {hour: price for hour, price in data.items() if hour >= self.today} - # publish <=48 hrs of data = calculations made on all data of yesterday and today (48 hrs) + # publish <=48 hrs of data = calculations made on all data of yesterday and today (48 hrs) elif self.calculation_mode == CALCULATION_MODE["publish"]: return { hour: price @@ -227,77 +272,54 @@ def _filter_calculated_hourprices(self, data): if hour >= self.today - timedelta(days=1) } - def get_prices_today(self): - return self.get_timestamped_prices(self.get_data_today()) - - def get_prices_tomorrow(self): - return self.get_timestamped_prices(self.get_data_tomorrow()) - - def get_prices(self): - if len(self.data) > 48: - return self.get_timestamped_prices( - {hour: price for hour, price in self.data.items() if hour >= self.today} - ) - return self.get_timestamped_prices( - { - hour: price - for hour, price in self.data.items() - if hour >= self.today - timedelta(days=1) - } - ) - - def get_data(self, date): - return {k: v for k, v in self.data.items() if k.date() == date.date()} - - def get_data_today(self): - return {k: v for k, v in self.data.items() if k.date() == self.today.date()} - - def get_data_tomorrow(self): - return { - k: v - for k, v in self.data.items() - if k.date() == self.today.date() + timedelta(days=1) - } - - def get_next_hourprice(self) -> int: - return self.data[ - dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) - ] - - def get_current_hourprice(self) -> int: - return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] - - def get_avg_price(self): - return round( - sum(self.filtered_hourprices.values()) - / len(self.filtered_hourprices.values()), - 5, - ) - + # ANALYSIS: Get max price in filtered period def get_max_price(self): return max(self.filtered_hourprices.values()) + # ANALYSIS: Get min price in filtered period def get_min_price(self): return min(self.filtered_hourprices.values()) + # ANALYSIS: Get timestamp of max price in filtered period def get_max_time(self): return max(self.filtered_hourprices, key=self.filtered_hourprices.get) + # ANALYSIS: Get timestamp of min price in filtered period def get_min_time(self): return min(self.filtered_hourprices, key=self.filtered_hourprices.get) + # ANALYSIS: Get avg price in filtered period + def get_avg_price(self): + return round( + sum(self.filtered_hourprices.values()) + / len(self.filtered_hourprices.values()), + 5, + ) + + # ANALYSIS: Get percentage of current price relative to maximum of filtered period def get_percentage_of_max(self): return round(self.get_current_hourprice() / self.get_max_price() * 100, 1) + # ANALYSIS: Get percentage of current price relative to spread (max-min) of filtered period def get_percentage_of_range(self): min = self.get_min_price() spread = self.get_max_price() - min current = self.get_current_hourprice() - min return round(current / spread * 100, 1) - - def get_timestamped_prices(self, hourprices): - list = [] - for hour, price in hourprices.items(): - str_hour = str(hour) - list.append({"time": str_hour, "price": price}) - return list + + # -------------------------------------------------------------------------------------------------------------------------------- + # SERVICES: returns data from the coordinator cache, or directly from ENTSO when not availble + # TODO: danger here for exceeding requests for huge periods suggest to limit to the 72 hrs of cached data + async def get_energy_prices(self, start_date, end_date): + # check if we have the data already + if ( + len(self.get_data(start_date)) > MIN_HOURS + and len(self.get_data(end_date)) > MIN_HOURS + ): + self.logger.debug(f"return prices from coordinator cache.") + return { + k: v + for k, v in self.data.items() + if k.date() >= start_date.date() and k.date() <= end_date.date() + } + return self.parse_hourprices(await self.fetch_prices(start_date, end_date)) From 139449d6e1577d72064d67c70af324360a751364 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:53:46 +0200 Subject: [PATCH 04/24] Apply the formatting again --- custom_components/entsoe/coordinator.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 1665672..398c140 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -239,10 +239,17 @@ def get_timestamped_prices(self, hourprices): # we could still optimize as not every calculator mode needs hourly updates def sync_calculator(self): now = dt.now() - if self.calculator_last_sync is None or self.calculator_last_sync.hour != now.hour: - self.logger.debug(f"The calculator needs to be synced with the current time") + if ( + self.calculator_last_sync is None + or self.calculator_last_sync.hour != now.hour + ): + self.logger.debug( + f"The calculator needs to be synced with the current time" + ) if self.today.date() != now.date(): - self.logger.debug(f"new day detected: update today and filtered hourprices") + self.logger.debug( + f"new day detected: update today and filtered hourprices" + ) self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) self.filtered_hourprices = self._filter_calculated_hourprices(self.data) From 7fce573949c5738cd7db9340fc69a3804aad2cdf Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:26:42 +0200 Subject: [PATCH 05/24] renamed some analysis windows --- custom_components/entsoe/const.py | 12 ++++++------ custom_components/entsoe/coordinator.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index ccfbb7b..e4f9766 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -21,13 +21,13 @@ # default is only for internal use / backwards compatibility CALCULATION_MODE = { - "default": "publish", - "publish": "publish", - "daily": "daily", - "sliding": "sliding", + "default": "published", + "published": "published", + "today": "today", + "sliding-24": "sliding-24", "sliding-12": "sliding-12", # new half day sliding - "forward": "forward", # 24hrs forward looking - "forward-12": "forward-12", # 12hrs forward looking + "forward-24": "forward-24", # 24hrs forward looking + "forward-12": "forward-12", # 12hrs forward looking } ENERGY_SCALES = { "kWh": 1000, "MWh": 1 } diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 9e7d349..5b831b9 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -192,7 +192,7 @@ def today_data_available(self): return len(self.get_data_today()) > MIN_HOURS def _filter_calculated_hourprices(self, data): - if self.calculation_mode == CALCULATION_MODE["daily"]: + if self.calculation_mode == CALCULATION_MODE["today"]: self.logger.debug(f"Filter dataset for prices today -> refresh each day") return { hour: price @@ -200,7 +200,7 @@ def _filter_calculated_hourprices(self, data): if hour >= self.today and hour < self.today + timedelta(days=1) } - elif self.calculation_mode == CALCULATION_MODE["sliding"]: + elif self.calculation_mode == CALCULATION_MODE["sliding-24"]: start = dt.now().replace(minute=0, second=0, microsecond=0) start -= timedelta(hours=12) end = start + timedelta(hours=24) @@ -214,7 +214,7 @@ def _filter_calculated_hourprices(self, data): self.logger.debug(f"Filter dataset to surrounding 12hrs {start} - {end} -> refresh each hour") return {hour: price for hour, price in data.items() if start < hour < end } - elif self.calculation_mode == CALCULATION_MODE["forward"]: + elif self.calculation_mode == CALCULATION_MODE["forward-24"]: start = dt.now().replace(minute=0, second=0, microsecond=0) end = start + timedelta(hours=24) self.logger.debug(f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour") @@ -226,8 +226,8 @@ def _filter_calculated_hourprices(self, data): self.logger.debug(f"Filter dataset to upcomming 12hrs {start} - {end} -> refresh each hour") return {hour: price for hour, price in data.items() if start < hour < end } - # default elif self.calculation_mode == CALCULATION_MODE["publish"]: - self.logger.debug(f"Do not filter the dataset, use the complete dataset as fetched") + # default elif self.calculation_mode == CALCULATION_MODE["published"]: + self.logger.debug(f"Do not filter the dataset, use the complete dataset as retrieved") return { hour: price for hour, price in data.items() } def get_prices_today(self): From 9ceda9372893e5d8c355fedf3a4e9a1d6cddfa6d Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:21:31 +0200 Subject: [PATCH 06/24] Introducting Analysis Windows --- README.md | 54 +++++---- custom_components/entsoe/__init__.py | 10 +- custom_components/entsoe/config_flow.py | 22 ++-- custom_components/entsoe/const.py | 8 +- custom_components/entsoe/coordinator.py | 148 ++++++++++++------------ custom_components/entsoe/sensor.py | 9 +- 6 files changed, 130 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 350fbe4..2494c33 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,14 @@ email address you entered during registration in the email body. ### Sensors The integration adds the following sensors: -- Average Day-Ahead Electricity Price Today (This integration carries attributes with all prices) -- Highest Day-Ahead Electricity Price Today -- Lowest Day-Ahead Electricity Price Today -- Current Day-Ahead Electricity Price -- Current Percentage Relative To Highest Electricity Price Of The Day -- Next Hour Day-Ahead Electricity Price +- Current Electricity Price +- Next Hour Electricity Price +#### Analysis sensors (dependent on configured analysis window) +- Average Day-Ahead Electricity Price (This integration carries attributes with all prices) +- Current Percentage Relative To Highest Electricity Price +- Current Percentage Relative To Spread Electricity Price +- Highest Day-Ahead Electricity Price +- Lowest Day-Ahead Electricity Price - Time Of Highest Energy Price Today - Time Of Lowest Energy Price Today @@ -79,24 +81,36 @@ An example template is given below. You can find and share other templates [here {% endif %} {% endif %} ``` -### Calculation method -This changes the calculated (min,max,avg values) entities behaviour to one of: +### Analysis Window (previously called Calculation method) +The analysis window defines which period to use for calculating the min,max,avg & perc values. The window can be set to: -- Sliding -The min/max/etc entities will get updated every hour with only upcoming data. -This means that the min price returned at 13:00 will be the lowest price in the future (as available from that point in time). -Regardless of past hours that might have had a lower price (this is most useful if you want to be able to schedule loads as soon and cheap as possible) +- Publish (Default) +The min/max/etc entities will get updated once new data becomes available (usualy between 12:00 and 15:00) +It also means that until the next days pricing becomes available the analysis is performed on the latest 48h of available data (yesterday and today) -- Default (on publish) -The min/max/etc entities will get updated once new data becomes available. -This means that the min price will update once the next days pricing becomes available (usually between 12:00 and 15:00) -It also means that until the next days pricing becomes available the latest 48h of available data will be used to calculate a min price +- Today +The analysis is performed on todays data. Sensor data will be updated at midnight -- Rotation -The min/max/etc entities will get updated at midnight. -This means that the min price returned at 23:59 will be based on the day x price while at 00:00 the day x+1 price will be the only one used in the calculations) -day x in this case is a random date like 2022-10-10 and day x+1 2022-10-11 +- Sliding-12 +An analysis window of 12 hours which moves along with the changing hour. Meaning the analysis sensors change each hour. +The window starts 6-hours before tha last hour and ends 6 hrs after. So its using a 12 hour window to detect half-day low-/high price periods + +- Sliding-24 +Same as above but using a 24 hour sliding analysis window + +- Forward-12 +Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. +Note: being updated each hour the timestamp of sensors (like minimum price) may just change before a trigger is fired by another, even lower price, getting included in the analysis window. As such teh turning on of a device may be delayed for another 12 hours. This continues while lower prices are being announced within a 12 hour timeframe. This may be helpfull when you want to charge your EV with the lowest price being forecasted +- Forward-24 +Same as above but using a 24 hour window. + +#### Legacy +- Sliding +Replaced by 'forward-24' + +- Rotation +Replaced by 'Today' ### ApexChart Graph Prices can be shown using the [ApexChart Graph Card](https://github.com/RomRider/apexcharts-card) like in the example above. The Lovelace code for this graph is given below: diff --git a/custom_components/entsoe/__init__.py b/custom_components/entsoe/__init__.py index 07477b7..9d513d4 100644 --- a/custom_components/entsoe/__init__.py +++ b/custom_components/entsoe/__init__.py @@ -10,11 +10,11 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - CALCULATION_MODE, + ANALYSIS_WINDOW, CONF_API_KEY, CONF_AREA, CONF_ENERGY_SCALE, - CONF_CALCULATION_MODE, + CONF_ANALYSIS_WINDOW, CONF_MODIFYER, CONF_VAT_VALUE, DEFAULT_MODIFYER, @@ -45,8 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: energy_scale = entry.options.get(CONF_ENERGY_SCALE, DEFAULT_ENERGY_SCALE) modifyer = entry.options.get(CONF_MODIFYER, DEFAULT_MODIFYER) vat = entry.options.get(CONF_VAT_VALUE, 0) - calculation_mode = entry.options.get( - CONF_CALCULATION_MODE, CALCULATION_MODE["default"] + analysis_window = entry.options.get( + CONF_ANALYSIS_WINDOW, ANALYSIS_WINDOW["default"] ) entsoe_coordinator = EntsoeCoordinator( hass, @@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: area=area, energy_scale=energy_scale, modifyer=modifyer, - calculation_mode=calculation_mode, + analysis_window=analysis_window, VAT=vat, ) diff --git a/custom_components/entsoe/config_flow.py b/custom_components/entsoe/config_flow.py index 318a002..24c54a5 100644 --- a/custom_components/entsoe/config_flow.py +++ b/custom_components/entsoe/config_flow.py @@ -21,12 +21,12 @@ from .const import ( AREA_INFO, - CALCULATION_MODE, + ANALYSIS_WINDOW, COMPONENT_TITLE, CONF_ADVANCED_OPTIONS, CONF_API_KEY, CONF_AREA, - CONF_CALCULATION_MODE, + CONF_ANALYSIS_WINDOW, CONF_CURRENCY, CONF_ENERGY_SCALE, CONF_ENTITY_NAME, @@ -94,7 +94,7 @@ async def async_step_user( user_input[CONF_MODIFYER] = DEFAULT_MODIFYER user_input[CONF_CURRENCY] = DEFAULT_CURRENCY user_input[CONF_ENERGY_SCALE] = DEFAULT_ENERGY_SCALE - user_input[CONF_CALCULATION_MODE] = CALCULATION_MODE["default"] + user_input[CONF_ANALYSIS_WINDOW] = ANALYSIS_WINDOW["default"] return self.async_create_entry( title=self.name or COMPONENT_TITLE, @@ -108,7 +108,7 @@ async def async_step_user( CONF_ADVANCED_OPTIONS: user_input[CONF_ADVANCED_OPTIONS], CONF_VAT_VALUE: user_input[CONF_VAT_VALUE], CONF_ENTITY_NAME: user_input[CONF_ENTITY_NAME], - CONF_CALCULATION_MODE: user_input[CONF_CALCULATION_MODE], + CONF_ANALYSIS_WINDOW: user_input[CONF_ANALYSIS_WINDOW], }, ) @@ -185,8 +185,8 @@ async def async_step_extra(self, user_input=None): CONF_ENERGY_SCALE: user_input[CONF_ENERGY_SCALE], CONF_VAT_VALUE: user_input[CONF_VAT_VALUE], CONF_ENTITY_NAME: user_input[CONF_ENTITY_NAME], - CONF_CALCULATION_MODE: user_input[ - CONF_CALCULATION_MODE + CONF_ANALYSIS_WINDOW: user_input[ + CONF_ANALYSIS_WINDOW ], }, ) @@ -212,12 +212,12 @@ async def async_step_extra(self, user_input=None): CONF_ENERGY_SCALE, default=DEFAULT_ENERGY_SCALE ): vol.In(list(ENERGY_SCALES.keys())), vol.Optional( - CONF_CALCULATION_MODE, default=CALCULATION_MODE["default"] + CONF_ANALYSIS_WINDOW, default=ANALYSIS_WINDOW["default"] ): SelectSelector( SelectSelectorConfig( options=[ SelectOptionDict(value=value, label=key) - for key, value in CALCULATION_MODE.items() + for key, value in ANALYSIS_WINDOW.items() if key != "default" ] ), @@ -286,7 +286,7 @@ async def async_step_init( errors["base"] = "invalid_template" calculation_mode_default = self.config_entry.options.get( - CONF_CALCULATION_MODE, CALCULATION_MODE["default"] + CONF_ANALYSIS_WINDOW, ANALYSIS_WINDOW["default"] ) return self.async_show_form( @@ -328,13 +328,13 @@ async def async_step_init( ), ): vol.In(list(ENERGY_SCALES.keys())), vol.Optional( - CONF_CALCULATION_MODE, + CONF_ANALYSIS_WINDOW, default=calculation_mode_default, ): SelectSelector( SelectSelectorConfig( options=[ SelectOptionDict(value=value, label=key) - for key, value in CALCULATION_MODE.items() + for key, value in ANALYSIS_WINDOW.items() if key != "default" ] ), diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index e4f9766..53f9a1a 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -12,7 +12,7 @@ CONF_CURRENCY = "currency" CONF_ENERGY_SCALE = "energy_scale" CONF_ADVANCED_OPTIONS = "advanced_options" -CONF_CALCULATION_MODE = "calculation_mode" +CONF_ANALYSIS_WINDOW = "analysis_window" CONF_VAT_VALUE = "VAT_value" DEFAULT_MODIFYER = "{{current_price}}" @@ -20,9 +20,9 @@ DEFAULT_ENERGY_SCALE = "kWh" # default is only for internal use / backwards compatibility -CALCULATION_MODE = { - "default": "published", - "published": "published", +ANALYSIS_WINDOW = { + "default": "publish", + "publish": "publish", "today": "today", "sliding-24": "sliding-24", "sliding-12": "sliding-12", # new half day sliding diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index e5fea21..c9b8d00 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -12,9 +12,10 @@ from requests.exceptions import HTTPError from .api_client import EntsoeClient -from .const import AREA_INFO, CALCULATION_MODE, DEFAULT_MODIFYER, ENERGY_SCALES +from .const import AREA_INFO, ANALYSIS_WINDOW, DEFAULT_MODIFYER, ENERGY_SCALES -# depending on timezone les than 24 hours could be returned. +# depending on timezone less than 24 hours could be returned. +# TODO: is this still a valid minimum now that we fill missing hours in the api_client? MIN_HOURS = 20 # This class contains actually two main tasks @@ -30,7 +31,7 @@ def __init__( area, energy_scale, modifyer, - calculation_mode=CALCULATION_MODE["default"], + analysis_window=ANALYSIS_WINDOW["default"], VAT=0, ) -> None: """Initialize the data object.""" @@ -39,10 +40,10 @@ def __init__( self.modifyer = modifyer self.area = AREA_INFO[area]["code"] self.energy_scale = energy_scale - self.calculation_mode = calculation_mode + self.analysis_window = analysis_window self.vat = VAT self.today = None - self.calculator_last_sync = None + self.last_analysis = None self.filtered_hourprices = [] # Check incase the sensor was setup using config flow. @@ -125,10 +126,10 @@ async def _async_update_data(self) -> dict: f"received pricing data from entso-e for {len(data)} hours" ) self.data = parsed_data - self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) + self.filtered_hourprices = self._filter_analysis_window(parsed_data) return parsed_data - # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs left for today, or less than 20hrs tomorrow and its after 11 + # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs for today, or less than 20hrs tomorrow and its after 11 def check_update_needed(self, now): if self.data is None: return True @@ -138,7 +139,7 @@ def check_update_needed(self, now): return True return False - # ENTSO: new prices using an async job + # ENTSO: fetch new prices using an async job async def fetch_prices(self, start_date, end_date): try: # run api_update in async job @@ -173,10 +174,22 @@ def api_update(self, start_date, end_date, api_key): country_code=self.area, start=start_date, end=end_date ) + # -------------------------------------------------------------------------------------------------------------------------------- # ENTSO: Return the data for the given date def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} + # ENTSO: Return a valid 48hrs dataset as in some occassions we only have 48hrs of data + # -> fetch starts after 11:00 after which we loose the data of the day before yesterday + # -> until we obtain tomorrow's data we only have 48hrs of data (of yesterday and today) + # -> after ~13:00 we will be back to 72hrs of cached data + def get_48hrs_data(self): + start = self.today # default we return 48hrs starting today + if len(self.data) <= 48: + start -= timedelta(days=1) # unless we dont have tomorrows data, then we start yesterday + + return {hour: price for hour, price in self.data.items() if hour >= start} + # ENTSO: Return the data for today def get_data_today(self): return self.get_data(self.today) @@ -189,10 +202,7 @@ def get_data_tomorrow(self): def get_data_yesterday(self): return self.get_data(self.today - timedelta(days=1)) - # SENSOR: Do we have data available for today TODO: remove - def today_data_available(self): - return len(self.get_data_today()) > MIN_HOURS - + # -------------------------------------------------------------------------------------------------------------------------------- # SENSOR: Get the current price def get_current_hourprice(self) -> int: return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] @@ -211,22 +221,11 @@ def get_prices_today(self): def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) - # SENSOR: Get timestamped prices of today & tomorrow or yesterday & today as attribute for Average Sensor - # TODO: why is there another logic when data is below 48 hrs and does this ever happen? + # SENSOR: Get timestamped 48hrs prices as attribute for Average Sensor def get_prices(self): - if len(self.data) > 48: - return self.get_timestamped_prices( - {hour: price for hour, price in self.data.items() if hour >= self.today} - ) - return self.get_timestamped_prices( - { - hour: price - for hour, price in self.data.items() - if hour >= self.today - timedelta(days=1) - } - ) + return self.get_timestamped_prices(self.get_48hrs_data()) - # SENSOR: Timestamp the prices + # SENSOR: Helper to timestamp the prices def get_timestamped_prices(self, hourprices): list = [] for hour, price in hourprices.items(): @@ -237,86 +236,85 @@ def get_timestamped_prices(self, hourprices): # -------------------------------------------------------------------------------------------------------------------------------- # ANALYSIS: this method is called by each sensor, each complete hour, and ensures the date and filtered hourprices are in line with the current time # we could still optimize as not every calculator mode needs hourly updates - def sync_calculator(self): + def refresh_analysis(self): now = dt.now() - if self.calculator_last_sync is None or self.calculator_last_sync.hour != now.hour: - self.logger.debug(f"The calculator needs to be synced with the current time") + if (self.last_analysis is None + or self.last_analysis.hour != now.hour + ): + self.logger.debug( + f"The analysis window needs to be updated to the current time" + ) if self.today.date() != now.date(): - self.logger.debug(f"new day detected: update today and filtered hourprices") + self.logger.debug( + f"new day detected: update today and filtered hourprices" + ) self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) - self.filtered_hourprices = self._filter_calculated_hourprices(self.data) + self.filtered_hourprices = self._filter_analysis_window(self.data) + + self.last_analysis = now - self.calculator_last_sync = now + # ANALYSIS: filter the hourprices on which to apply the analysis + def _filter_analysis_window(self, data): + last_hour = dt.now().replace(minute=0, second=0, microsecond=0) - # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode - def _filter_calculated_hourprices(self, data): - if self.calculation_mode == CALCULATION_MODE["today"]: + if self.analysis_window == ANALYSIS_WINDOW["today"]: self.logger.debug(f"Filter dataset for prices today -> refresh each day") - return { - hour: price - for hour, price in data.items() - if hour >= self.today and hour < self.today + timedelta(days=1) - } - - elif self.calculation_mode == CALCULATION_MODE["sliding-24"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) - start -= timedelta(hours=12) - end = start + timedelta(hours=24) + start = self.today + end = start + timedelta(days=1) + + elif self.analysis_window == ANALYSIS_WINDOW["sliding-24"]: + start = last_hour - timedelta(hours=12) + end = start + timedelta(hours=24) self.logger.debug(f"Filter dataset to surrounding 24hrs {start} - {end} -> refresh each hour") - return {hour: price for hour, price in data.items() if start < hour < end } - elif self.calculation_mode == CALCULATION_MODE["sliding-12"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) - start -= timedelta(hours=6) - end = start + timedelta(hours=12) + elif self.analysis_window == ANALYSIS_WINDOW["sliding-12"]: + start = last_hour - timedelta(hours=6) + end = start + timedelta(hours=12) self.logger.debug(f"Filter dataset to surrounding 12hrs {start} - {end} -> refresh each hour") - return {hour: price for hour, price in data.items() if start < hour < end } - elif self.calculation_mode == CALCULATION_MODE["forward-24"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) - end = start + timedelta(hours=24) + elif self.analysis_window == ANALYSIS_WINDOW["forward-24"]: + start = last_hour + end = start + timedelta(hours=24) self.logger.debug(f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour") - return {hour: price for hour, price in data.items() if start < hour < end } - elif self.calculation_mode == CALCULATION_MODE["forward-12"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) - end = start + timedelta(hours=12) + elif self.analysis_window == ANALYSIS_WINDOW["forward-12"]: + start = last_hour + end = start + timedelta(hours=12) self.logger.debug(f"Filter dataset to upcomming 12hrs {start} - {end} -> refresh each hour") - return {hour: price for hour, price in data.items() if start < hour < end } - # default elif self.calculation_mode == CALCULATION_MODE["published"]: - self.logger.debug(f"Do not filter the dataset, use the complete dataset as retrieved") - return { hour: price for hour, price in data.items() } + else: # self.analysis_window == ANALYSIS_WINDOW["publish"]: + self.logger.debug(f"Do not filter the dataset, use the 48hrs dataset as retrieved") + return self.get_48hrs_data() - # ANALYSIS: Get max price in filtered period + return {hour: price for hour, price in data.items() if start < hour < end } + + # ANALYSIS: Get max price in analysis window def get_max_price(self): return max(self.filtered_hourprices.values()) - # ANALYSIS: Get min price in filtered period + # ANALYSIS: Get min price in analysis window def get_min_price(self): return min(self.filtered_hourprices.values()) - # ANALYSIS: Get timestamp of max price in filtered period + # ANALYSIS: Get timestamp of max price in analysis window def get_max_time(self): return max(self.filtered_hourprices, key=self.filtered_hourprices.get) - # ANALYSIS: Get timestamp of min price in filtered period + # ANALYSIS: Get timestamp of min price in analysis window def get_min_time(self): return min(self.filtered_hourprices, key=self.filtered_hourprices.get) - # ANALYSIS: Get avg price in filtered period + # ANALYSIS: Get avg price in analysis window + # TODO import mean() from statistics def get_avg_price(self): - return round( - sum(self.filtered_hourprices.values()) - / len(self.filtered_hourprices.values()), - 5, - ) + prices = self.filtered_hourprices.values() + return round(sum(prices) / len(prices), 5) - # ANALYSIS: Get percentage of current price relative to maximum of filtered period + # ANALYSIS: Get percentage of current price relative to maximum in analysis window def get_percentage_of_max(self): return round(self.get_current_hourprice() / self.get_max_price() * 100, 1) - # ANALYSIS: Get percentage of current price relative to spread (max-min) of filtered period + # ANALYSIS: Get percentage of current price relative to spread (max-min) of analysis window def get_percentage_of_range(self): min = self.get_min_price() spread = self.get_max_price() - min @@ -325,7 +323,7 @@ def get_percentage_of_range(self): # -------------------------------------------------------------------------------------------------------------------------------- # SERVICES: returns data from the coordinator cache, or directly from ENTSO when not availble - # TODO: danger here for exceeding requests for huge periods suggest to limit to the 72 hrs of cached data + # TODO: danger here for processing requests with huge periods -> suggest to limit to the 72 hrs of cached data async def get_energy_prices(self, start_date, end_date): # check if we have the data already if ( diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 4538f67..1347027 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -224,13 +224,10 @@ async def async_update(self) -> None: utcnow().replace(minute=0, second=0) + timedelta(hours=1), ) - # ensure the calculated data is refreshed by the changing hour - self.coordinator.sync_calculator() + # ensure the analysis is refreshed by the changing hour + self.coordinator.refresh_analysis() - if ( - self.coordinator.data is not None - and self.coordinator.today_data_available() - ): + if self.coordinator.data is not None: value: Any = None try: # _LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") From 1d15d7a701c861a0c27eba15ccf7d98a704be907 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:39:47 +0200 Subject: [PATCH 07/24] Readme updates --- README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2494c33..cc8a6b5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ email address you entered during registration in the email body. The integration adds the following sensors: - Current Electricity Price - Next Hour Electricity Price -#### Analysis sensors (dependent on configured analysis window) + +And some price analysis sensors: - Average Day-Ahead Electricity Price (This integration carries attributes with all prices) - Current Percentage Relative To Highest Electricity Price - Current Percentage Relative To Spread Electricity Price @@ -82,35 +83,43 @@ An example template is given below. You can find and share other templates [here {% endif %} ``` ### Analysis Window (previously called Calculation method) -The analysis window defines which period to use for calculating the min,max,avg & perc values. The window can be set to: +The analysis window defines which period to use for calculating the min,max,avg & perc values. + +The analysis window can be set to: - Publish (Default) + The min/max/etc entities will get updated once new data becomes available (usualy between 12:00 and 15:00) It also means that until the next days pricing becomes available the analysis is performed on the latest 48h of available data (yesterday and today) - Today + The analysis is performed on todays data. Sensor data will be updated at midnight - Sliding-12 + An analysis window of 12 hours which moves along with the changing hour. Meaning the analysis sensors change each hour. The window starts 6-hours before tha last hour and ends 6 hrs after. So its using a 12 hour window to detect half-day low-/high price periods - Sliding-24 + Same as above but using a 24 hour sliding analysis window - Forward-12 + Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. + Note: being updated each hour the timestamp of sensors (like minimum price) may just change before a trigger is fired by another, even lower price, getting included in the analysis window. As such teh turning on of a device may be delayed for another 12 hours. This continues while lower prices are being announced within a 12 hour timeframe. This may be helpfull when you want to charge your EV with the lowest price being forecasted - Forward-24 + Same as above but using a 24 hour window. -#### Legacy -- Sliding -Replaced by 'forward-24' +- Sliding depricated. Please use 'forward-24' + +- Rotation depricated. Please use 'Today' + -- Rotation -Replaced by 'Today' ### ApexChart Graph Prices can be shown using the [ApexChart Graph Card](https://github.com/RomRider/apexcharts-card) like in the example above. The Lovelace code for this graph is given below: From 716ea8bd31ec2811b8776a509d401cd3dbfcd91f Mon Sep 17 00:00:00 2001 From: Erik Veer <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:48:39 +0200 Subject: [PATCH 08/24] Added analysis window picture --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc8a6b5..9e9c1e4 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ An example template is given below. You can find and share other templates [here ### Analysis Window (previously called Calculation method) The analysis window defines which period to use for calculating the min,max,avg & perc values. +![image](https://github.com/user-attachments/assets/c7978e26-1fa9-417b-9e2f-830f8b4ccd1f) + The analysis window can be set to: - Publish (Default) @@ -109,7 +111,7 @@ Same as above but using a 24 hour sliding analysis window Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. -Note: being updated each hour the timestamp of sensors (like minimum price) may just change before a trigger is fired by another, even lower price, getting included in the analysis window. As such teh turning on of a device may be delayed for another 12 hours. This continues while lower prices are being announced within a 12 hour timeframe. This may be helpfull when you want to charge your EV with the lowest price being forecasted +Note: being updated each hour the values may just change before a trigger is fired and as such not fire it. For example the timestamp of the minum price may just be set to a later date, jsut before you thought it would trigger an event. This may be caused by another lower minum getting included in the shifted analysis window. This may continue to happen while lower prices are being announced within a 12 hour timeframe. This may still be helpfull when you want to charge your EV with the lowest price being forecasted - Forward-24 From 8d02fc38de6fe1b9cd11c4600f73969bace7be36 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 21:22:23 +0200 Subject: [PATCH 09/24] Data fetch needs to call refresh_analysis --- custom_components/entsoe/coordinator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index c9b8d00..b02d24d 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -126,7 +126,8 @@ async def _async_update_data(self) -> dict: f"received pricing data from entso-e for {len(data)} hours" ) self.data = parsed_data - self.filtered_hourprices = self._filter_analysis_window(parsed_data) + self.last_analysis = None # data was updated so force a refresh of the analysis + self.refresh_analysis() return parsed_data # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs for today, or less than 20hrs tomorrow and its after 11 @@ -258,9 +259,9 @@ def _filter_analysis_window(self, data): last_hour = dt.now().replace(minute=0, second=0, microsecond=0) if self.analysis_window == ANALYSIS_WINDOW["today"]: - self.logger.debug(f"Filter dataset for prices today -> refresh each day") start = self.today end = start + timedelta(days=1) + self.logger.debug(f"Filter dataset for prices today {start} - {end} -> refresh each day") elif self.analysis_window == ANALYSIS_WINDOW["sliding-24"]: start = last_hour - timedelta(hours=12) @@ -283,7 +284,7 @@ def _filter_analysis_window(self, data): self.logger.debug(f"Filter dataset to upcomming 12hrs {start} - {end} -> refresh each hour") else: # self.analysis_window == ANALYSIS_WINDOW["publish"]: - self.logger.debug(f"Do not filter the dataset, use the 48hrs dataset as retrieved") + self.logger.debug(f"Window is set to {self.analysis_window} and therefore we use the 48hrs dataset") return self.get_48hrs_data() return {hour: price for hour, price in data.items() if start < hour < end } From 5f8a1166d3b174038c1eba37aabae84a6c756828 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 00:47:48 +0200 Subject: [PATCH 10/24] As long as we do not have data for tommorrow, we return yesterday and today --- custom_components/entsoe/coordinator.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index b02d24d..535ca43 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -185,12 +185,15 @@ def get_data(self, date): # -> until we obtain tomorrow's data we only have 48hrs of data (of yesterday and today) # -> after ~13:00 we will be back to 72hrs of cached data def get_48hrs_data(self): - start = self.today # default we return 48hrs starting today - if len(self.data) <= 48: - start -= timedelta(days=1) # unless we dont have tomorrows data, then we start yesterday + today = self.get_data_today() + tommorrow = self.get_data_tomorrow() - return {hour: price for hour, price in self.data.items() if hour >= start} + if len(tommorrow) < MIN_HOURS: + yesterday = self.get_data_yesterday() + return {**yesterday, **today } + return {**today, **tommorrow} + # ENTSO: Return the data for today def get_data_today(self): return self.get_data(self.today) @@ -214,15 +217,19 @@ def get_next_hourprice(self) -> int: dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) ] - # SENSOR: Get timestamped prices of today as attribute for Average Sensor + # SENSOR: Get timestamped prices of today def get_prices_today(self): return self.get_timestamped_prices(self.get_data_today()) - # SENSOR: Get timestamped prices of tomorrow as attribute for Average Sensor + # SENSOR: Get timestamped prices of tomorrow def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) - # SENSOR: Get timestamped 48hrs prices as attribute for Average Sensor + # SENSOR: Get timestamped prices of yesterday + def get_prices_yesterday(self): + return self.get_timestamped_prices(self.get_data_yesterday()) + + # SENSOR: Get timestamped 48hrs prices def get_prices(self): return self.get_timestamped_prices(self.get_48hrs_data()) From 71f47db43c515d5e66fe9a4a898e107a5a7f9edb Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 00:57:38 +0200 Subject: [PATCH 11/24] Change comment --- custom_components/entsoe/coordinator.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 535ca43..fd86789 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -181,18 +181,17 @@ def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} # ENTSO: Return a valid 48hrs dataset as in some occassions we only have 48hrs of data - # -> fetch starts after 11:00 after which we loose the data of the day before yesterday - # -> until we obtain tomorrow's data we only have 48hrs of data (of yesterday and today) - # -> after ~13:00 we will be back to 72hrs of cached data + # when we fetch data between 0:00 and ~13:00 we will only get yesterdays and todays data (48hrs) + # after ~13:00 we will be back to 72hrs of cached data, including tomorrows def get_48hrs_data(self): today = self.get_data_today() - tommorrow = self.get_data_tomorrow() + tomorrow = self.get_data_tomorrow() - if len(tommorrow) < MIN_HOURS: + if len(tomorrow) < MIN_HOURS: yesterday = self.get_data_yesterday() return {**yesterday, **today } - return {**today, **tommorrow} + return {**today, **tomorrow} # ENTSO: Return the data for today def get_data_today(self): From 2569b335f01da16b8b464370dd4d2137fa8d6f77 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:00:22 +0200 Subject: [PATCH 12/24] Code Format --- custom_components/entsoe/coordinator.py | 33 +++++++++++-------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 398c140..9da423b 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta +from datetime import timedelta import homeassistant.helpers.config_validation as cv from homeassistant.core import HomeAssistant @@ -17,6 +17,7 @@ # depending on timezone les than 24 hours could be returned. MIN_HOURS = 20 + # This class contains actually two main tasks # 1. ENTSO: Refresh data from ENTSO on interval basis triggered by HASS every 60 minutes # 2. ANALYSIS: Implement some analysis on this data, like min(), max(), avg(), perc(). Updated analysis is triggered by an explicit call from a sensor @@ -64,7 +65,7 @@ def __init__( update_interval=timedelta(minutes=60), ) - # ENTSO: recalculate the price using the given template + # ENTSO: recalculate the price using the given template def calc_price(self, value, fake_dt=None, no_template=False) -> float: """Calculate price based on the users settings.""" # Used to inject the current hour. @@ -107,7 +108,7 @@ async def _async_update_data(self) -> dict: now = dt.now() self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) if self.check_update_needed(now) is False: - self.logger.debug(f"Skipping api fetch. All data is already available") + self.logger.debug("Skipping api fetch. All data is already available") return self.data yesterday = self.today - timedelta(days=1) @@ -172,7 +173,7 @@ def api_update(self, start_date, end_date, api_key): return client.query_day_ahead_prices( country_code=self.area, start=start_date, end=end_date ) - + # ENTSO: Return the data for the given date def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} @@ -189,10 +190,10 @@ def get_data_tomorrow(self): def get_data_yesterday(self): return self.get_data(self.today - timedelta(days=1)) - # SENSOR: Do we have data available for today TODO: remove + # SENSOR: Do we have data available for today def today_data_available(self): return len(self.get_data_today()) > MIN_HOURS - + # SENSOR: Get the current price def get_current_hourprice(self) -> int: return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] @@ -212,7 +213,6 @@ def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) # SENSOR: Get timestamped prices of today & tomorrow or yesterday & today as attribute for Average Sensor - # TODO: why is there another logic when data is below 48 hrs and does this ever happen? def get_prices(self): if len(self.data) > 48: return self.get_timestamped_prices( @@ -240,22 +240,20 @@ def get_timestamped_prices(self, hourprices): def sync_calculator(self): now = dt.now() if ( - self.calculator_last_sync is None + self.calculator_last_sync is None or self.calculator_last_sync.hour != now.hour ): - self.logger.debug( - f"The calculator needs to be synced with the current time" - ) + self.logger.debug("The calculator needs to be synced with the current time") if self.today.date() != now.date(): self.logger.debug( - f"new day detected: update today and filtered hourprices" + "new day detected: update today and filtered hourprices" ) self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) self.filtered_hourprices = self._filter_calculated_hourprices(self.data) - self.calculator_last_sync = now + self.calculator_last_sync = now - # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode + # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode def _filter_calculated_hourprices(self, data): # rotation = calculations made upon 24hrs today if self.calculation_mode == CALCULATION_MODE["rotation"]: @@ -271,7 +269,7 @@ def _filter_calculated_hourprices(self, data): # publish >48 hrs of data = calculations made on all data of today and tomorrow (48 hrs) elif self.calculation_mode == CALCULATION_MODE["publish"] and len(data) > 48: return {hour: price for hour, price in data.items() if hour >= self.today} - # publish <=48 hrs of data = calculations made on all data of yesterday and today (48 hrs) + # publish <=48 hrs of data = calculations made on all data of yesterday and today (48 hrs) elif self.calculation_mode == CALCULATION_MODE["publish"]: return { hour: price @@ -313,17 +311,16 @@ def get_percentage_of_range(self): spread = self.get_max_price() - min current = self.get_current_hourprice() - min return round(current / spread * 100, 1) - + # -------------------------------------------------------------------------------------------------------------------------------- # SERVICES: returns data from the coordinator cache, or directly from ENTSO when not availble - # TODO: danger here for exceeding requests for huge periods suggest to limit to the 72 hrs of cached data async def get_energy_prices(self, start_date, end_date): # check if we have the data already if ( len(self.get_data(start_date)) > MIN_HOURS and len(self.get_data(end_date)) > MIN_HOURS ): - self.logger.debug(f"return prices from coordinator cache.") + self.logger.debug("return prices from coordinator cache.") return { k: v for k, v in self.data.items() From cdd2a9547a779d0aab490567a977241716c57e01 Mon Sep 17 00:00:00 2001 From: Erik Veer <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:15:38 +0200 Subject: [PATCH 13/24] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e9c1e4..8668340 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,16 @@ Same as above but using a 24 hour sliding analysis window Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. -Note: being updated each hour the values may just change before a trigger is fired and as such not fire it. For example the timestamp of the minum price may just be set to a later date, jsut before you thought it would trigger an event. This may be caused by another lower minum getting included in the shifted analysis window. This may continue to happen while lower prices are being announced within a 12 hour timeframe. This may still be helpfull when you want to charge your EV with the lowest price being forecasted +Note that because the sensors are updated each hour, the values may change just before you would expect a trigger to be fired. For example the timestamp of the minimum price may change to a later date when the analysis window shifts one hour and by this got another lower minimum price, included in the dataset. This situation may continue while lower prices keep on turning up in future hours while shifting the window. It may however help you to charge your EV at the lowest price in the comming days - Forward-24 Same as above but using a 24 hour window. -- Sliding depricated. Please use 'forward-24' +Depricated +- Sliding. Please use 'forward-24' -- Rotation depricated. Please use 'Today' +- Rotation. Please use 'Today' From 0df5db6be12af3dff3473a68f95e16dcac69c0dc Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:14:49 +0200 Subject: [PATCH 14/24] Refresh of calculation methods --- custom_components/entsoe/const.py | 11 +++-- custom_components/entsoe/coordinator.py | 56 ++++++++++++++++++------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index 99f1e4c..7e43c25 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -21,10 +21,13 @@ # default is only for internal use / backwards compatibility CALCULATION_MODE = { - "default": "publish", - "rotation": "rotation", - "sliding": "sliding", - "publish": "publish", + "default": "publish", + "publish": "publish", + "daily": "daily", + "sliding": "sliding", + "sliding-12": "sliding-12", # new half day sliding + "forecast": "forecast", # 24hrs forward looking + "forecast-12": "forecast-12", # 12hrs forward looking } ENERGY_SCALES = { "kWh": 1000, "MWh": 1 } diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 9da423b..18f7c9e 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -255,27 +255,53 @@ def sync_calculator(self): # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode def _filter_calculated_hourprices(self, data): - # rotation = calculations made upon 24hrs today - if self.calculation_mode == CALCULATION_MODE["rotation"]: + if self.calculation_mode == CALCULATION_MODE["daily"]: + self.logger.debug(f"Filter dataset for prices today -> refresh each day") return { hour: price for hour, price in data.items() if hour >= self.today and hour < self.today + timedelta(days=1) } - # sliding = calculations made on all data from the current hour and beyond (future data only) + elif self.calculation_mode == CALCULATION_MODE["sliding"]: - now = dt.now().replace(minute=0, second=0, microsecond=0) - return {hour: price for hour, price in data.items() if hour >= now} - # publish >48 hrs of data = calculations made on all data of today and tomorrow (48 hrs) - elif self.calculation_mode == CALCULATION_MODE["publish"] and len(data) > 48: - return {hour: price for hour, price in data.items() if hour >= self.today} - # publish <=48 hrs of data = calculations made on all data of yesterday and today (48 hrs) - elif self.calculation_mode == CALCULATION_MODE["publish"]: - return { - hour: price - for hour, price in data.items() - if hour >= self.today - timedelta(days=1) - } + start = dt.now().replace(minute=0, second=0, microsecond=0) + start -= timedelta(hours=12) + end = start + timedelta(hours=24) + self.logger.debug( + f"Filter dataset to surrounding 24hrs {start} - {end} -> refresh each hour" + ) + return {hour: price for hour, price in data.items() if start < hour < end} + + elif self.calculation_mode == CALCULATION_MODE["sliding-12"]: + start = dt.now().replace(minute=0, second=0, microsecond=0) + start -= timedelta(hours=6) + end = start + timedelta(hours=12) + self.logger.debug( + f"Filter dataset to surrounding 12hrs {start} - {end} -> refresh each hour" + ) + return {hour: price for hour, price in data.items() if start < hour < end} + + elif self.calculation_mode == CALCULATION_MODE["forecast"]: + start = dt.now().replace(minute=0, second=0, microsecond=0) + end = start + timedelta(hours=24) + self.logger.debug( + f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour" + ) + return {hour: price for hour, price in data.items() if start < hour < end} + + elif self.calculation_mode == CALCULATION_MODE["forecast-12"]: + start = dt.now().replace(minute=0, second=0, microsecond=0) + end = start + timedelta(hours=12) + self.logger.debug( + f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour" + ) + return {hour: price for hour, price in data.items() if start < hour < end} + + # default elif self.calculation_mode == CALCULATION_MODE["publish"]: + self.logger.debug( + f"Do not filter the dataset, use the complete dataset as fetched" + ) + return {hour: price for hour, price in data.items()} # ANALYSIS: Get max price in filtered period def get_max_price(self): From a204bf5448a15555a8dcb18717da078c59f6a036 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:34:25 +0200 Subject: [PATCH 15/24] Some small adjustements --- custom_components/entsoe/const.py | 4 ++-- custom_components/entsoe/coordinator.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index 7e43c25..ccfbb7b 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -26,8 +26,8 @@ "daily": "daily", "sliding": "sliding", "sliding-12": "sliding-12", # new half day sliding - "forecast": "forecast", # 24hrs forward looking - "forecast-12": "forecast-12", # 12hrs forward looking + "forward": "forward", # 24hrs forward looking + "forward-12": "forward-12", # 12hrs forward looking } ENERGY_SCALES = { "kWh": 1000, "MWh": 1 } diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 18f7c9e..03f27bb 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -281,7 +281,7 @@ def _filter_calculated_hourprices(self, data): ) return {hour: price for hour, price in data.items() if start < hour < end} - elif self.calculation_mode == CALCULATION_MODE["forecast"]: + elif self.calculation_mode == CALCULATION_MODE["forward"]: start = dt.now().replace(minute=0, second=0, microsecond=0) end = start + timedelta(hours=24) self.logger.debug( From 06fbeae6408feb1a30678c01ea32a11033103979 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:26:42 +0200 Subject: [PATCH 16/24] renamed some analysis windows --- custom_components/entsoe/const.py | 12 ++++++------ custom_components/entsoe/coordinator.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index ccfbb7b..e4f9766 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -21,13 +21,13 @@ # default is only for internal use / backwards compatibility CALCULATION_MODE = { - "default": "publish", - "publish": "publish", - "daily": "daily", - "sliding": "sliding", + "default": "published", + "published": "published", + "today": "today", + "sliding-24": "sliding-24", "sliding-12": "sliding-12", # new half day sliding - "forward": "forward", # 24hrs forward looking - "forward-12": "forward-12", # 12hrs forward looking + "forward-24": "forward-24", # 24hrs forward looking + "forward-12": "forward-12", # 12hrs forward looking } ENERGY_SCALES = { "kWh": 1000, "MWh": 1 } diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 03f27bb..30513d4 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -255,7 +255,7 @@ def sync_calculator(self): # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode def _filter_calculated_hourprices(self, data): - if self.calculation_mode == CALCULATION_MODE["daily"]: + if self.calculation_mode == CALCULATION_MODE["today"]: self.logger.debug(f"Filter dataset for prices today -> refresh each day") return { hour: price @@ -263,7 +263,7 @@ def _filter_calculated_hourprices(self, data): if hour >= self.today and hour < self.today + timedelta(days=1) } - elif self.calculation_mode == CALCULATION_MODE["sliding"]: + elif self.calculation_mode == CALCULATION_MODE["sliding-24"]: start = dt.now().replace(minute=0, second=0, microsecond=0) start -= timedelta(hours=12) end = start + timedelta(hours=24) @@ -281,7 +281,7 @@ def _filter_calculated_hourprices(self, data): ) return {hour: price for hour, price in data.items() if start < hour < end} - elif self.calculation_mode == CALCULATION_MODE["forward"]: + elif self.calculation_mode == CALCULATION_MODE["forward-24"]: start = dt.now().replace(minute=0, second=0, microsecond=0) end = start + timedelta(hours=24) self.logger.debug( From b5cec2001001c0ce15b09e67a278172090d1fbaf Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:21:31 +0200 Subject: [PATCH 17/24] Introducting Analysis Windows --- README.md | 54 +++++---- custom_components/entsoe/__init__.py | 10 +- custom_components/entsoe/config_flow.py | 22 ++-- custom_components/entsoe/const.py | 8 +- custom_components/entsoe/coordinator.py | 143 ++++++++++++------------ custom_components/entsoe/sensor.py | 9 +- 6 files changed, 126 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 350fbe4..2494c33 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,14 @@ email address you entered during registration in the email body. ### Sensors The integration adds the following sensors: -- Average Day-Ahead Electricity Price Today (This integration carries attributes with all prices) -- Highest Day-Ahead Electricity Price Today -- Lowest Day-Ahead Electricity Price Today -- Current Day-Ahead Electricity Price -- Current Percentage Relative To Highest Electricity Price Of The Day -- Next Hour Day-Ahead Electricity Price +- Current Electricity Price +- Next Hour Electricity Price +#### Analysis sensors (dependent on configured analysis window) +- Average Day-Ahead Electricity Price (This integration carries attributes with all prices) +- Current Percentage Relative To Highest Electricity Price +- Current Percentage Relative To Spread Electricity Price +- Highest Day-Ahead Electricity Price +- Lowest Day-Ahead Electricity Price - Time Of Highest Energy Price Today - Time Of Lowest Energy Price Today @@ -79,24 +81,36 @@ An example template is given below. You can find and share other templates [here {% endif %} {% endif %} ``` -### Calculation method -This changes the calculated (min,max,avg values) entities behaviour to one of: +### Analysis Window (previously called Calculation method) +The analysis window defines which period to use for calculating the min,max,avg & perc values. The window can be set to: -- Sliding -The min/max/etc entities will get updated every hour with only upcoming data. -This means that the min price returned at 13:00 will be the lowest price in the future (as available from that point in time). -Regardless of past hours that might have had a lower price (this is most useful if you want to be able to schedule loads as soon and cheap as possible) +- Publish (Default) +The min/max/etc entities will get updated once new data becomes available (usualy between 12:00 and 15:00) +It also means that until the next days pricing becomes available the analysis is performed on the latest 48h of available data (yesterday and today) -- Default (on publish) -The min/max/etc entities will get updated once new data becomes available. -This means that the min price will update once the next days pricing becomes available (usually between 12:00 and 15:00) -It also means that until the next days pricing becomes available the latest 48h of available data will be used to calculate a min price +- Today +The analysis is performed on todays data. Sensor data will be updated at midnight -- Rotation -The min/max/etc entities will get updated at midnight. -This means that the min price returned at 23:59 will be based on the day x price while at 00:00 the day x+1 price will be the only one used in the calculations) -day x in this case is a random date like 2022-10-10 and day x+1 2022-10-11 +- Sliding-12 +An analysis window of 12 hours which moves along with the changing hour. Meaning the analysis sensors change each hour. +The window starts 6-hours before tha last hour and ends 6 hrs after. So its using a 12 hour window to detect half-day low-/high price periods + +- Sliding-24 +Same as above but using a 24 hour sliding analysis window + +- Forward-12 +Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. +Note: being updated each hour the timestamp of sensors (like minimum price) may just change before a trigger is fired by another, even lower price, getting included in the analysis window. As such teh turning on of a device may be delayed for another 12 hours. This continues while lower prices are being announced within a 12 hour timeframe. This may be helpfull when you want to charge your EV with the lowest price being forecasted +- Forward-24 +Same as above but using a 24 hour window. + +#### Legacy +- Sliding +Replaced by 'forward-24' + +- Rotation +Replaced by 'Today' ### ApexChart Graph Prices can be shown using the [ApexChart Graph Card](https://github.com/RomRider/apexcharts-card) like in the example above. The Lovelace code for this graph is given below: diff --git a/custom_components/entsoe/__init__.py b/custom_components/entsoe/__init__.py index 07477b7..9d513d4 100644 --- a/custom_components/entsoe/__init__.py +++ b/custom_components/entsoe/__init__.py @@ -10,11 +10,11 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - CALCULATION_MODE, + ANALYSIS_WINDOW, CONF_API_KEY, CONF_AREA, CONF_ENERGY_SCALE, - CONF_CALCULATION_MODE, + CONF_ANALYSIS_WINDOW, CONF_MODIFYER, CONF_VAT_VALUE, DEFAULT_MODIFYER, @@ -45,8 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: energy_scale = entry.options.get(CONF_ENERGY_SCALE, DEFAULT_ENERGY_SCALE) modifyer = entry.options.get(CONF_MODIFYER, DEFAULT_MODIFYER) vat = entry.options.get(CONF_VAT_VALUE, 0) - calculation_mode = entry.options.get( - CONF_CALCULATION_MODE, CALCULATION_MODE["default"] + analysis_window = entry.options.get( + CONF_ANALYSIS_WINDOW, ANALYSIS_WINDOW["default"] ) entsoe_coordinator = EntsoeCoordinator( hass, @@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: area=area, energy_scale=energy_scale, modifyer=modifyer, - calculation_mode=calculation_mode, + analysis_window=analysis_window, VAT=vat, ) diff --git a/custom_components/entsoe/config_flow.py b/custom_components/entsoe/config_flow.py index 318a002..24c54a5 100644 --- a/custom_components/entsoe/config_flow.py +++ b/custom_components/entsoe/config_flow.py @@ -21,12 +21,12 @@ from .const import ( AREA_INFO, - CALCULATION_MODE, + ANALYSIS_WINDOW, COMPONENT_TITLE, CONF_ADVANCED_OPTIONS, CONF_API_KEY, CONF_AREA, - CONF_CALCULATION_MODE, + CONF_ANALYSIS_WINDOW, CONF_CURRENCY, CONF_ENERGY_SCALE, CONF_ENTITY_NAME, @@ -94,7 +94,7 @@ async def async_step_user( user_input[CONF_MODIFYER] = DEFAULT_MODIFYER user_input[CONF_CURRENCY] = DEFAULT_CURRENCY user_input[CONF_ENERGY_SCALE] = DEFAULT_ENERGY_SCALE - user_input[CONF_CALCULATION_MODE] = CALCULATION_MODE["default"] + user_input[CONF_ANALYSIS_WINDOW] = ANALYSIS_WINDOW["default"] return self.async_create_entry( title=self.name or COMPONENT_TITLE, @@ -108,7 +108,7 @@ async def async_step_user( CONF_ADVANCED_OPTIONS: user_input[CONF_ADVANCED_OPTIONS], CONF_VAT_VALUE: user_input[CONF_VAT_VALUE], CONF_ENTITY_NAME: user_input[CONF_ENTITY_NAME], - CONF_CALCULATION_MODE: user_input[CONF_CALCULATION_MODE], + CONF_ANALYSIS_WINDOW: user_input[CONF_ANALYSIS_WINDOW], }, ) @@ -185,8 +185,8 @@ async def async_step_extra(self, user_input=None): CONF_ENERGY_SCALE: user_input[CONF_ENERGY_SCALE], CONF_VAT_VALUE: user_input[CONF_VAT_VALUE], CONF_ENTITY_NAME: user_input[CONF_ENTITY_NAME], - CONF_CALCULATION_MODE: user_input[ - CONF_CALCULATION_MODE + CONF_ANALYSIS_WINDOW: user_input[ + CONF_ANALYSIS_WINDOW ], }, ) @@ -212,12 +212,12 @@ async def async_step_extra(self, user_input=None): CONF_ENERGY_SCALE, default=DEFAULT_ENERGY_SCALE ): vol.In(list(ENERGY_SCALES.keys())), vol.Optional( - CONF_CALCULATION_MODE, default=CALCULATION_MODE["default"] + CONF_ANALYSIS_WINDOW, default=ANALYSIS_WINDOW["default"] ): SelectSelector( SelectSelectorConfig( options=[ SelectOptionDict(value=value, label=key) - for key, value in CALCULATION_MODE.items() + for key, value in ANALYSIS_WINDOW.items() if key != "default" ] ), @@ -286,7 +286,7 @@ async def async_step_init( errors["base"] = "invalid_template" calculation_mode_default = self.config_entry.options.get( - CONF_CALCULATION_MODE, CALCULATION_MODE["default"] + CONF_ANALYSIS_WINDOW, ANALYSIS_WINDOW["default"] ) return self.async_show_form( @@ -328,13 +328,13 @@ async def async_step_init( ), ): vol.In(list(ENERGY_SCALES.keys())), vol.Optional( - CONF_CALCULATION_MODE, + CONF_ANALYSIS_WINDOW, default=calculation_mode_default, ): SelectSelector( SelectSelectorConfig( options=[ SelectOptionDict(value=value, label=key) - for key, value in CALCULATION_MODE.items() + for key, value in ANALYSIS_WINDOW.items() if key != "default" ] ), diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index e4f9766..53f9a1a 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -12,7 +12,7 @@ CONF_CURRENCY = "currency" CONF_ENERGY_SCALE = "energy_scale" CONF_ADVANCED_OPTIONS = "advanced_options" -CONF_CALCULATION_MODE = "calculation_mode" +CONF_ANALYSIS_WINDOW = "analysis_window" CONF_VAT_VALUE = "VAT_value" DEFAULT_MODIFYER = "{{current_price}}" @@ -20,9 +20,9 @@ DEFAULT_ENERGY_SCALE = "kWh" # default is only for internal use / backwards compatibility -CALCULATION_MODE = { - "default": "published", - "published": "published", +ANALYSIS_WINDOW = { + "default": "publish", + "publish": "publish", "today": "today", "sliding-24": "sliding-24", "sliding-12": "sliding-12", # new half day sliding diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 30513d4..ebd1716 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -12,9 +12,10 @@ from requests.exceptions import HTTPError from .api_client import EntsoeClient -from .const import AREA_INFO, CALCULATION_MODE, DEFAULT_MODIFYER, ENERGY_SCALES +from .const import AREA_INFO, ANALYSIS_WINDOW, DEFAULT_MODIFYER, ENERGY_SCALES -# depending on timezone les than 24 hours could be returned. +# depending on timezone less than 24 hours could be returned. +# TODO: is this still a valid minimum now that we fill missing hours in the api_client? MIN_HOURS = 20 @@ -31,7 +32,7 @@ def __init__( area, energy_scale, modifyer, - calculation_mode=CALCULATION_MODE["default"], + analysis_window=ANALYSIS_WINDOW["default"], VAT=0, ) -> None: """Initialize the data object.""" @@ -40,10 +41,10 @@ def __init__( self.modifyer = modifyer self.area = AREA_INFO[area]["code"] self.energy_scale = energy_scale - self.calculation_mode = calculation_mode + self.analysis_window = analysis_window self.vat = VAT self.today = None - self.calculator_last_sync = None + self.last_analysis = None self.filtered_hourprices = [] # Check incase the sensor was setup using config flow. @@ -126,10 +127,10 @@ async def _async_update_data(self) -> dict: f"received pricing data from entso-e for {len(data)} hours" ) self.data = parsed_data - self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) + self.filtered_hourprices = self._filter_analysis_window(parsed_data) return parsed_data - # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs left for today, or less than 20hrs tomorrow and its after 11 + # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs for today, or less than 20hrs tomorrow and its after 11 def check_update_needed(self, now): if self.data is None: return True @@ -139,7 +140,7 @@ def check_update_needed(self, now): return True return False - # ENTSO: new prices using an async job + # ENTSO: fetch new prices using an async job async def fetch_prices(self, start_date, end_date): try: # run api_update in async job @@ -174,10 +175,24 @@ def api_update(self, start_date, end_date, api_key): country_code=self.area, start=start_date, end=end_date ) + # -------------------------------------------------------------------------------------------------------------------------------- # ENTSO: Return the data for the given date def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} + # ENTSO: Return a valid 48hrs dataset as in some occassions we only have 48hrs of data + # -> fetch starts after 11:00 after which we loose the data of the day before yesterday + # -> until we obtain tomorrow's data we only have 48hrs of data (of yesterday and today) + # -> after ~13:00 we will be back to 72hrs of cached data + def get_48hrs_data(self): + start = self.today # default we return 48hrs starting today + if len(self.data) <= 48: + start -= timedelta( + days=1 + ) # unless we dont have tomorrows data, then we start yesterday + + return {hour: price for hour, price in self.data.items() if hour >= start} + # ENTSO: Return the data for today def get_data_today(self): return self.get_data(self.today) @@ -190,10 +205,7 @@ def get_data_tomorrow(self): def get_data_yesterday(self): return self.get_data(self.today - timedelta(days=1)) - # SENSOR: Do we have data available for today - def today_data_available(self): - return len(self.get_data_today()) > MIN_HOURS - + # -------------------------------------------------------------------------------------------------------------------------------- # SENSOR: Get the current price def get_current_hourprice(self) -> int: return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] @@ -212,21 +224,11 @@ def get_prices_today(self): def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) - # SENSOR: Get timestamped prices of today & tomorrow or yesterday & today as attribute for Average Sensor + # SENSOR: Get timestamped 48hrs prices as attribute for Average Sensor def get_prices(self): - if len(self.data) > 48: - return self.get_timestamped_prices( - {hour: price for hour, price in self.data.items() if hour >= self.today} - ) - return self.get_timestamped_prices( - { - hour: price - for hour, price in self.data.items() - if hour >= self.today - timedelta(days=1) - } - ) + return self.get_timestamped_prices(self.get_48hrs_data()) - # SENSOR: Timestamp the prices + # SENSOR: Helper to timestamp the prices def get_timestamped_prices(self, hourprices): list = [] for hour, price in hourprices.items(): @@ -237,101 +239,93 @@ def get_timestamped_prices(self, hourprices): # -------------------------------------------------------------------------------------------------------------------------------- # ANALYSIS: this method is called by each sensor, each complete hour, and ensures the date and filtered hourprices are in line with the current time # we could still optimize as not every calculator mode needs hourly updates - def sync_calculator(self): + def refresh_analysis(self): now = dt.now() - if ( - self.calculator_last_sync is None - or self.calculator_last_sync.hour != now.hour - ): - self.logger.debug("The calculator needs to be synced with the current time") + if self.last_analysis is None or self.last_analysis.hour != now.hour: + self.logger.debug( + f"The analysis window needs to be updated to the current time" + ) if self.today.date() != now.date(): self.logger.debug( - "new day detected: update today and filtered hourprices" + f"new day detected: update today and filtered hourprices" ) self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) - self.filtered_hourprices = self._filter_calculated_hourprices(self.data) + self.filtered_hourprices = self._filter_analysis_window(self.data) + + self.last_analysis = now - self.calculator_last_sync = now + # ANALYSIS: filter the hourprices on which to apply the analysis + def _filter_analysis_window(self, data): + last_hour = dt.now().replace(minute=0, second=0, microsecond=0) - # ANALYSIS: filter the hourprices on which to apply the calculations based on the calculation_mode - def _filter_calculated_hourprices(self, data): - if self.calculation_mode == CALCULATION_MODE["today"]: + if self.analysis_window == ANALYSIS_WINDOW["today"]: self.logger.debug(f"Filter dataset for prices today -> refresh each day") - return { - hour: price - for hour, price in data.items() - if hour >= self.today and hour < self.today + timedelta(days=1) - } + start = self.today + end = start + timedelta(days=1) - elif self.calculation_mode == CALCULATION_MODE["sliding-24"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) - start -= timedelta(hours=12) + elif self.analysis_window == ANALYSIS_WINDOW["sliding-24"]: + start = last_hour - timedelta(hours=12) end = start + timedelta(hours=24) self.logger.debug( f"Filter dataset to surrounding 24hrs {start} - {end} -> refresh each hour" ) - return {hour: price for hour, price in data.items() if start < hour < end} - elif self.calculation_mode == CALCULATION_MODE["sliding-12"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) - start -= timedelta(hours=6) + elif self.analysis_window == ANALYSIS_WINDOW["sliding-12"]: + start = last_hour - timedelta(hours=6) end = start + timedelta(hours=12) self.logger.debug( f"Filter dataset to surrounding 12hrs {start} - {end} -> refresh each hour" ) - return {hour: price for hour, price in data.items() if start < hour < end} - elif self.calculation_mode == CALCULATION_MODE["forward-24"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) + elif self.analysis_window == ANALYSIS_WINDOW["forward-24"]: + start = last_hour end = start + timedelta(hours=24) self.logger.debug( f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour" ) - return {hour: price for hour, price in data.items() if start < hour < end} - elif self.calculation_mode == CALCULATION_MODE["forecast-12"]: - start = dt.now().replace(minute=0, second=0, microsecond=0) + elif self.analysis_window == ANALYSIS_WINDOW["forward-12"]: + start = last_hour end = start + timedelta(hours=12) self.logger.debug( - f"Filter dataset to upcomming 24hrs {start} - {end} -> refresh each hour" + f"Filter dataset to upcomming 12hrs {start} - {end} -> refresh each hour" ) - return {hour: price for hour, price in data.items() if start < hour < end} - # default elif self.calculation_mode == CALCULATION_MODE["publish"]: - self.logger.debug( - f"Do not filter the dataset, use the complete dataset as fetched" - ) - return {hour: price for hour, price in data.items()} + else: # self.analysis_window == ANALYSIS_WINDOW["publish"]: + self.logger.debug( + f"Do not filter the dataset, use the 48hrs dataset as retrieved" + ) + return self.get_48hrs_data() - # ANALYSIS: Get max price in filtered period + return {hour: price for hour, price in data.items() if start < hour < end} + + # ANALYSIS: Get max price in analysis window def get_max_price(self): return max(self.filtered_hourprices.values()) - # ANALYSIS: Get min price in filtered period + # ANALYSIS: Get min price in analysis window def get_min_price(self): return min(self.filtered_hourprices.values()) - # ANALYSIS: Get timestamp of max price in filtered period + # ANALYSIS: Get timestamp of max price in analysis window def get_max_time(self): return max(self.filtered_hourprices, key=self.filtered_hourprices.get) - # ANALYSIS: Get timestamp of min price in filtered period + # ANALYSIS: Get timestamp of min price in analysis window def get_min_time(self): return min(self.filtered_hourprices, key=self.filtered_hourprices.get) - # ANALYSIS: Get avg price in filtered period + # ANALYSIS: Get avg price in analysis window + # TODO import mean() from statistics def get_avg_price(self): - return round( - sum(self.filtered_hourprices.values()) - / len(self.filtered_hourprices.values()), - 5, - ) + prices = self.filtered_hourprices.values() + return round(sum(prices) / len(prices), 5) - # ANALYSIS: Get percentage of current price relative to maximum of filtered period + # ANALYSIS: Get percentage of current price relative to maximum in analysis window def get_percentage_of_max(self): return round(self.get_current_hourprice() / self.get_max_price() * 100, 1) - # ANALYSIS: Get percentage of current price relative to spread (max-min) of filtered period + # ANALYSIS: Get percentage of current price relative to spread (max-min) of analysis window def get_percentage_of_range(self): min = self.get_min_price() spread = self.get_max_price() - min @@ -340,6 +334,7 @@ def get_percentage_of_range(self): # -------------------------------------------------------------------------------------------------------------------------------- # SERVICES: returns data from the coordinator cache, or directly from ENTSO when not availble + # TODO: danger here for processing requests with huge periods -> suggest to limit to the 72 hrs of cached data async def get_energy_prices(self, start_date, end_date): # check if we have the data already if ( diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 4538f67..1347027 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -224,13 +224,10 @@ async def async_update(self) -> None: utcnow().replace(minute=0, second=0) + timedelta(hours=1), ) - # ensure the calculated data is refreshed by the changing hour - self.coordinator.sync_calculator() + # ensure the analysis is refreshed by the changing hour + self.coordinator.refresh_analysis() - if ( - self.coordinator.data is not None - and self.coordinator.today_data_available() - ): + if self.coordinator.data is not None: value: Any = None try: # _LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") From fd7d178d27813f286e6fe05ddc2730021ca35587 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:39:47 +0200 Subject: [PATCH 18/24] Readme updates --- README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2494c33..cc8a6b5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ email address you entered during registration in the email body. The integration adds the following sensors: - Current Electricity Price - Next Hour Electricity Price -#### Analysis sensors (dependent on configured analysis window) + +And some price analysis sensors: - Average Day-Ahead Electricity Price (This integration carries attributes with all prices) - Current Percentage Relative To Highest Electricity Price - Current Percentage Relative To Spread Electricity Price @@ -82,35 +83,43 @@ An example template is given below. You can find and share other templates [here {% endif %} ``` ### Analysis Window (previously called Calculation method) -The analysis window defines which period to use for calculating the min,max,avg & perc values. The window can be set to: +The analysis window defines which period to use for calculating the min,max,avg & perc values. + +The analysis window can be set to: - Publish (Default) + The min/max/etc entities will get updated once new data becomes available (usualy between 12:00 and 15:00) It also means that until the next days pricing becomes available the analysis is performed on the latest 48h of available data (yesterday and today) - Today + The analysis is performed on todays data. Sensor data will be updated at midnight - Sliding-12 + An analysis window of 12 hours which moves along with the changing hour. Meaning the analysis sensors change each hour. The window starts 6-hours before tha last hour and ends 6 hrs after. So its using a 12 hour window to detect half-day low-/high price periods - Sliding-24 + Same as above but using a 24 hour sliding analysis window - Forward-12 + Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. + Note: being updated each hour the timestamp of sensors (like minimum price) may just change before a trigger is fired by another, even lower price, getting included in the analysis window. As such teh turning on of a device may be delayed for another 12 hours. This continues while lower prices are being announced within a 12 hour timeframe. This may be helpfull when you want to charge your EV with the lowest price being forecasted - Forward-24 + Same as above but using a 24 hour window. -#### Legacy -- Sliding -Replaced by 'forward-24' +- Sliding depricated. Please use 'forward-24' + +- Rotation depricated. Please use 'Today' + -- Rotation -Replaced by 'Today' ### ApexChart Graph Prices can be shown using the [ApexChart Graph Card](https://github.com/RomRider/apexcharts-card) like in the example above. The Lovelace code for this graph is given below: From abc8554bc00043ed2b33f2731abe86cea7cbd2ff Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 21:22:23 +0200 Subject: [PATCH 19/24] Data fetch needs to call refresh_analysis --- custom_components/entsoe/coordinator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index ebd1716..dfd1198 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -127,7 +127,10 @@ async def _async_update_data(self) -> dict: f"received pricing data from entso-e for {len(data)} hours" ) self.data = parsed_data - self.filtered_hourprices = self._filter_analysis_window(parsed_data) + self.last_analysis = ( + None # data was updated so force a refresh of the analysis + ) + self.refresh_analysis() return parsed_data # ENTSO: check if we need to refresh the data. If we have None, or less than 20hrs for today, or less than 20hrs tomorrow and its after 11 @@ -259,9 +262,11 @@ def _filter_analysis_window(self, data): last_hour = dt.now().replace(minute=0, second=0, microsecond=0) if self.analysis_window == ANALYSIS_WINDOW["today"]: - self.logger.debug(f"Filter dataset for prices today -> refresh each day") start = self.today end = start + timedelta(days=1) + self.logger.debug( + f"Filter dataset for prices today {start} - {end} -> refresh each day" + ) elif self.analysis_window == ANALYSIS_WINDOW["sliding-24"]: start = last_hour - timedelta(hours=12) @@ -293,7 +298,7 @@ def _filter_analysis_window(self, data): else: # self.analysis_window == ANALYSIS_WINDOW["publish"]: self.logger.debug( - f"Do not filter the dataset, use the 48hrs dataset as retrieved" + f"Window is set to {self.analysis_window} and therefore we use the 48hrs dataset" ) return self.get_48hrs_data() From ccab3786583a0a333175189d26cbfe1775b35fef Mon Sep 17 00:00:00 2001 From: Erik Veer <124380379+Pluimvee@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:48:39 +0200 Subject: [PATCH 20/24] Added analysis window picture --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc8a6b5..9e9c1e4 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ An example template is given below. You can find and share other templates [here ### Analysis Window (previously called Calculation method) The analysis window defines which period to use for calculating the min,max,avg & perc values. +![image](https://github.com/user-attachments/assets/c7978e26-1fa9-417b-9e2f-830f8b4ccd1f) + The analysis window can be set to: - Publish (Default) @@ -109,7 +111,7 @@ Same as above but using a 24 hour sliding analysis window Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. -Note: being updated each hour the timestamp of sensors (like minimum price) may just change before a trigger is fired by another, even lower price, getting included in the analysis window. As such teh turning on of a device may be delayed for another 12 hours. This continues while lower prices are being announced within a 12 hour timeframe. This may be helpfull when you want to charge your EV with the lowest price being forecasted +Note: being updated each hour the values may just change before a trigger is fired and as such not fire it. For example the timestamp of the minum price may just be set to a later date, jsut before you thought it would trigger an event. This may be caused by another lower minum getting included in the shifted analysis window. This may continue to happen while lower prices are being announced within a 12 hour timeframe. This may still be helpfull when you want to charge your EV with the lowest price being forecasted - Forward-24 From 4162d7529d7478df7db1cd3d7eb3b67e2cd7c13d Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 00:47:48 +0200 Subject: [PATCH 21/24] As long as we do not have data for tommorrow, we return yesterday and today --- custom_components/entsoe/coordinator.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index dfd1198..de53817 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -188,13 +188,14 @@ def get_data(self, date): # -> until we obtain tomorrow's data we only have 48hrs of data (of yesterday and today) # -> after ~13:00 we will be back to 72hrs of cached data def get_48hrs_data(self): - start = self.today # default we return 48hrs starting today - if len(self.data) <= 48: - start -= timedelta( - days=1 - ) # unless we dont have tomorrows data, then we start yesterday + today = self.get_data_today() + tommorrow = self.get_data_tomorrow() - return {hour: price for hour, price in self.data.items() if hour >= start} + if len(tommorrow) < MIN_HOURS: + yesterday = self.get_data_yesterday() + return {**yesterday, **today} + + return {**today, **tommorrow} # ENTSO: Return the data for today def get_data_today(self): @@ -219,15 +220,19 @@ def get_next_hourprice(self) -> int: dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) ] - # SENSOR: Get timestamped prices of today as attribute for Average Sensor + # SENSOR: Get timestamped prices of today def get_prices_today(self): return self.get_timestamped_prices(self.get_data_today()) - # SENSOR: Get timestamped prices of tomorrow as attribute for Average Sensor + # SENSOR: Get timestamped prices of tomorrow def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) - # SENSOR: Get timestamped 48hrs prices as attribute for Average Sensor + # SENSOR: Get timestamped prices of yesterday + def get_prices_yesterday(self): + return self.get_timestamped_prices(self.get_data_yesterday()) + + # SENSOR: Get timestamped 48hrs prices def get_prices(self): return self.get_timestamped_prices(self.get_48hrs_data()) From 5d56da659a1b4544b284784fa5d998b16b507f98 Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 00:57:38 +0200 Subject: [PATCH 22/24] Change comment --- custom_components/entsoe/coordinator.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index de53817..a6e366b 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -184,18 +184,17 @@ def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} # ENTSO: Return a valid 48hrs dataset as in some occassions we only have 48hrs of data - # -> fetch starts after 11:00 after which we loose the data of the day before yesterday - # -> until we obtain tomorrow's data we only have 48hrs of data (of yesterday and today) - # -> after ~13:00 we will be back to 72hrs of cached data + # when we fetch data between 0:00 and ~13:00 we will only get yesterdays and todays data (48hrs) + # after ~13:00 we will be back to 72hrs of cached data, including tomorrows def get_48hrs_data(self): today = self.get_data_today() - tommorrow = self.get_data_tomorrow() + tomorrow = self.get_data_tomorrow() - if len(tommorrow) < MIN_HOURS: + if len(tomorrow) < MIN_HOURS: yesterday = self.get_data_yesterday() return {**yesterday, **today} - return {**today, **tommorrow} + return {**today, **tomorrow} # ENTSO: Return the data for today def get_data_today(self): From ad90cf5e797c39db0014ad3f7b6e5c2130f88678 Mon Sep 17 00:00:00 2001 From: Erik Veer <124380379+Pluimvee@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:15:38 +0200 Subject: [PATCH 23/24] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e9c1e4..8668340 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,16 @@ Same as above but using a 24 hour sliding analysis window Same 12 hours sliding window, however starting from the last hour upto 12 hours beyond. Usefull to detect half-day min/max values whih occur in the future. -Note: being updated each hour the values may just change before a trigger is fired and as such not fire it. For example the timestamp of the minum price may just be set to a later date, jsut before you thought it would trigger an event. This may be caused by another lower minum getting included in the shifted analysis window. This may continue to happen while lower prices are being announced within a 12 hour timeframe. This may still be helpfull when you want to charge your EV with the lowest price being forecasted +Note that because the sensors are updated each hour, the values may change just before you would expect a trigger to be fired. For example the timestamp of the minimum price may change to a later date when the analysis window shifts one hour and by this got another lower minimum price, included in the dataset. This situation may continue while lower prices keep on turning up in future hours while shifting the window. It may however help you to charge your EV at the lowest price in the comming days - Forward-24 Same as above but using a 24 hour window. -- Sliding depricated. Please use 'forward-24' +Depricated +- Sliding. Please use 'forward-24' -- Rotation depricated. Please use 'Today' +- Rotation. Please use 'Today' From 940726a21a746e95f7b7f841d40544674dfe200c Mon Sep 17 00:00:00 2001 From: Pluimvee <124380379+Pluimvee@users.noreply.github.com> Date: Sun, 20 Oct 2024 14:34:28 +0200 Subject: [PATCH 24/24] Some additional code comments --- custom_components/entsoe/coordinator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 50f5c27..305a2e9 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -183,9 +183,11 @@ def api_update(self, start_date, end_date, api_key): def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} - # ENTSO: Return a valid 48hrs dataset as in some occassions we only have 48hrs of data - # when we fetch data between 0:00 and ~13:00 we will only get yesterdays and todays data (48hrs) - # after ~13:00 we will be back to 72hrs of cached data, including tomorrows + # ENTSO: Return the most recent 48hrs dataset + # We limit ourselves to 48 hrs as + # - On reboot between 0:00 and ~13:00 we only have 48hrs of data + # - On operations passing the 0:00 we have data of today, yesterday and the day before yesterday + # - After ~13:00 we will be back to 72hrs of yesterday, today and tomorrow def get_48hrs_data(self): today = self.get_data_today() tomorrow = self.get_data_tomorrow()