Repository: azogue/aiopvpc

Issue:
sensor.pvpc: repeated bad data update, mark component as unavailable source
Home Assistant shows the following error in the **pvpc_hourly_pricing** integration:

> Logger: aiopvpc.pvpc_data
> Source: /usr/local/lib/python3.9/site-packages/aiopvpc/pvpc_data.py:231
> ...
> Forbidden error with 'https://api.esios.ree.es/archives/70/download_json?locale=es&date=2021-11-29' -> Headers: ...

It seems the same happened before: https://github.com/azogue/aiopvpc/issues/31

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b77b485..60265cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
 # Changelog
 
+## [v2.3.0](https://github.com/azogue/aiopvpc/tree/v2.3.0) - Decrease API refresh rate and try to avoid banning (2021-12-01)
+
+[Full Changelog](https://github.com/azogue/aiopvpc/compare/v2.3.0...v2.2.4)
+
+Quick-Fix Release motivated by the last change in the ESIOS server (on 2021-11-30 😱),
+which is now apparently banning HomeAssistant requests,
+filtering us out because of the 'User-Agent' headers data 😤,
+as the server is returning a 403 status code error for a PUBLIC url 🤷.
+
+**Changes:**
+
+* :zap: **Substantially decrease the number of API requests to ESIOS**,
+avoiding unnecesary calls to refresh data for the same electricity prices.
+  Before, when used from the `pvpc_hourly_pricing` HA Core integration,
+the ESIOS API was called 2 times/hour from 0h to 20h, and 4 times/hour in the evening,
+from 20h to 0h, retrieving today + tomorrow prices.
+  This makes a total of ~56 requests/day, which is _not a lot_ 😅,
+but it seems the aggregated total for the HA user base (🔥 >30k requests/day just
+counting users pushing HA analytics) is being some kind of a problem for ESIOS,
+as it looks like they're trying to bane us 🥺😭
+  Now, the API handler avoids calls to retrieve already available prices,
+cutting down the number of requests to just 1-2 requests/day 🤩
+
+* :bug: **Set standard `User-Agent` header info**, to try to avoid server-side banning 🙈,
+and _rotate_ it if banning is detected, using common User-Agent browser identifiers.
+
+* :recycle: Minor code refactor to prepare for future library changes, in order to move to authenticated API endpoints in future versions.
+
 ## [v2.2.4](https://github.com/azogue/aiopvpc/tree/v2.2.4) - Split today / tomorrow price sensor attributes (2021-11-20)
 
 [Full Changelog](https://github.com/azogue/aiopvpc/compare/v2.2.4...v2.2.2)
diff --git a/aiopvpc/pvpc_download.py b/aiopvpc/parser.py
similarity index 76%
rename from aiopvpc/pvpc_download.py
rename to aiopvpc/parser.py
index 865b4d9..3b1abab 100644
--- a/aiopvpc/pvpc_download.py
+++ b/aiopvpc/parser.py
@@ -4,21 +4,10 @@ Simple aio library to download Spanish electricity hourly prices.
 * URL for JSON daily files
 * Parser for the contents of the JSON files
 """
-from datetime import date, datetime, timedelta
+from datetime import datetime, timedelta
 from typing import Any, Dict, Optional, Union
 
-from aiopvpc.const import (
-    PRICE_PRECISION,
-    REFERENCE_TZ,
-    URL_PVPC_RESOURCE,
-    UTC_TZ,
-    zoneinfo,
-)
-
-
-def get_url_for_daily_json(day: Union[date, datetime]) -> str:
-    """Get URL for JSON file with PVPC data for a specific day (in Europe/Madrid TZ)."""
-    return URL_PVPC_RESOURCE.format(day=day)
+from aiopvpc.const import PRICE_PRECISION, REFERENCE_TZ, UTC_TZ, zoneinfo
 
 
 def extract_pvpc_data(
diff --git a/aiopvpc/pvpc_data.py b/aiopvpc/pvpc_data.py
index 069e7fe..91c9724 100644
--- a/aiopvpc/pvpc_data.py
+++ b/aiopvpc/pvpc_data.py
@@ -7,7 +7,9 @@ https://www.home-assistant.io/integrations/pvpc_hourly_pricing/
 """
 import asyncio
 import logging
+from collections import deque
 from datetime import date, datetime, timedelta
+from random import random
 from time import monotonic
 from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
 
@@ -24,25 +26,37 @@ from aiopvpc.const import (
     REFERENCE_TZ,
     TARIFF2ID,
     TARIFFS,
+    URL_PVPC_RESOURCE,
     UTC_TZ,
     zoneinfo,
 )
+from aiopvpc.parser import extract_pvpc_data
 from aiopvpc.prices import make_price_sensor_attributes
-from aiopvpc.pvpc_download import extract_pvpc_data, get_url_for_daily_json
 from aiopvpc.pvpc_tariff import get_current_and_next_tariff_periods
-
-_REQUEST_HEADERS = {
-    "User-Agent": "aioPVPC Python library",
-    "Accept": "application/json",
-}
-
-
-def _ensure_utc_time(ts: datetime):
-    if ts.tzinfo is None:
-        return datetime(*ts.timetuple()[:6], tzinfo=UTC_TZ)
-    elif str(ts.tzinfo) != str(UTC_TZ):
-        return ts.astimezone(UTC_TZ)
-    return ts
+from aiopvpc.utils import ensure_utc_time
+
+_LOGGER = logging.getLogger(__name__)
+
+# 🙈😱 Use randomized standard User-Agent info to avoid server banning 😖🤷
+_STANDARD_USER_AGENTS = [
+    (
+        "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) "
+        "Gecko/20100101 Firefox/47.3"
+    ),
+    (
+        "Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) "
+        "Gecko/20100101 Firefox/43.4"
+    ),
+    (
+        "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
+        "(KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36"
+    ),
+    (
+        "Mozilla/5.0 (iPhone; CPU iPhone OS 11_3_1 like Mac OS X) "
+        "AppleWebKit/603.1.30 (KHTML, like Gecko)"
+    ),
+    "Version/10.0 Mobile/14E304 Safari/602.1",
+]
 
 
 class PVPCData:
@@ -69,7 +83,6 @@ class PVPCData:
         zone_ceuta_melilla: bool = False,
         power: float = DEFAULT_POWER_KW,
         power_valley: float = DEFAULT_POWER_KW,
-        logger: Optional[logging.Logger] = None,
         timeout: float = DEFAULT_TIMEOUT,
     ):
         self.source_available = True
@@ -79,9 +92,14 @@ class PVPCData:
 
         self.timeout = timeout
         self._session = websession
+        self._user_agents = deque(sorted(_STANDARD_USER_AGENTS, key=lambda x: random()))
+        self._headers = {
+            "User-Agent": self._user_agents[0],
+            "Accept": "application/json",
+        }
+
         self._with_initial_session = websession is not None
         self._local_timezone = zoneinfo.ZoneInfo(str(local_timezone))
-        self._logger = logger or logging.getLogger(__name__)
 
         self._current_prices: Dict[datetime, float] = {}
         self._power = power
@@ -89,7 +107,7 @@ class PVPCData:
         self._zone_ceuta_melilla = zone_ceuta_melilla
         if tariff is None:
             self.tariff = self.tariff_old = None
-            self._logger.warning("Collecting detailed PVPC data for all tariffs")
+            _LOGGER.warning("Collecting detailed PVPC data for all tariffs")
         elif tariff in OLD_TARIFFS:
             self.tariff_old = tariff
             self.tariff = TARIFFS[1] if zone_ceuta_melilla else TARIFFS[0]
@@ -97,7 +115,7 @@ class PVPCData:
             self.tariff_old = "discrimination"
             self.tariff = tariff
             if tariff not in TARIFFS:  # pragma: no cover
-                self._logger.error(
+                _LOGGER.error(
                     "Unknown tariff '%s'. Should be one of %s, or, "
                     "if using it to retrieve old prices, one of %s",
                     tariff,
@@ -106,6 +124,35 @@ class PVPCData:
                 )
                 self.tariff = TARIFFS[1] if zone_ceuta_melilla else TARIFFS[0]
 
+    async def _api_get_prices(
+        self, url: str, tariff: Optional[str]
+    ) -> Dict[datetime, Any]:
+        assert self._session is not None
+        resp = await self._session.get(url, headers=self._headers)
+        if resp.status < 400:
+            data = await resp.json()
+            return extract_pvpc_data(data, tariff, tz=self._local_timezone)
+        elif resp.status == 403:  # pragma: no cover
+            _LOGGER.warning(
+                "Forbidden error with '%s' -> Headers: %s", url, resp.headers
+            )
+            # loop user-agent
+            self._user_agents.rotate()
+            self._headers["User-Agent"] = self._user_agents[0]
+            # and retry
+            resp = await self._session.get(url, headers=self._headers)
+            if resp.status < 400:
+                data = await resp.json()
+                return extract_pvpc_data(data, tariff, tz=self._local_timezone)
+            elif resp.status == 403:
+                _LOGGER.error(
+                    "Repeaed forbidden error with '%s' -> Headers: %s",
+                    url,
+                    resp.headers,
+                )
+                self._headers.pop("User-Agent")
+        return {}
+
     async def _download_pvpc_prices(self, day: date) -> Dict[datetime, Any]:
         """
         PVPC data extractor.
@@ -118,31 +165,23 @@ class PVPCData:
 
         Prices are referenced with datetimes in UTC.
         """
-        url = get_url_for_daily_json(day)
-        assert self._session is not None
+        url = URL_PVPC_RESOURCE.format(day=day)
         if day < DATE_CHANGE_TO_20TD:
             tariff = OLD_TARIFF2ID.get(self.tariff_old) if self.tariff_old else None
         else:
             tariff = TARIFF2ID.get(self.tariff) if self.tariff else None
 
         try:
-            async with async_timeout.timeout(self.timeout):
-                resp = await self._session.get(url, headers=_REQUEST_HEADERS)
-                if resp.status < 400:
-                    data = await resp.json()
-                    return extract_pvpc_data(data, tariff, tz=self._local_timezone)
-                elif resp.status == 403:  # pragma: no cover
-                    self._logger.error(
-                        "Forbidden error with '%s' -> Headers:  %s", url, resp.headers
-                    )
+            async with async_timeout.timeout(2 * self.timeout):
+                return await self._api_get_prices(url, tariff)
         except KeyError:
-            self._logger.debug("Bad try on getting prices for %s", day)
+            _LOGGER.debug("Bad try on getting prices for %s", day)
         except asyncio.TimeoutError:
             if self.source_available:
-                self._logger.warning("Timeout error requesting data from '%s'", url)
+                _LOGGER.warning("Timeout error requesting data from '%s'", url)
         except aiohttp.ClientError:
             if self.source_available:
-                self._logger.warning("Client error in '%s'", url)
+                _LOGGER.warning("Client error in '%s'", url)
         return {}
 
     async def async_update_prices(self, now: datetime) -> Dict[datetime, float]:
@@ -153,11 +192,63 @@ class PVPCData:
         If not, it is converted to UTC from the original timezone,
         or set as UTC-time if it is a naive datetime.
         """
-        utc_now = _ensure_utc_time(now)
+        utc_now = ensure_utc_time(now)
         local_ref_now = utc_now.astimezone(REFERENCE_TZ)
-        prices = await self._download_pvpc_prices(local_ref_now.date())
-        if not prices:
-            return prices
+        current_num_prices = len(self._current_prices)
+        if local_ref_now.hour >= 20 and current_num_prices > 30:
+            # already have today+tomorrow prices, avoid requests
+            _LOGGER.debug(
+                "Evening download avoided, now with %d prices from %s UTC",
+                current_num_prices,
+                list(self._current_prices)[0].strftime("%Y-%m-%d %Hh"),
+            )
+            return self._current_prices
+        elif (
+            local_ref_now.hour < 20
+            and current_num_prices > 20
+            and (
+                list(self._current_prices)[-12].astimezone(REFERENCE_TZ).date()
+                == local_ref_now.date()
+            )
+        ):
+            # already have today prices, avoid request
+            _LOGGER.debug(
+                "Download avoided, now with %d prices up to %s UTC",
+                current_num_prices,
+                list(self._current_prices)[-1].strftime("%Y-%m-%d %Hh"),
+            )
+            return self._current_prices
+
+        if current_num_prices and (
+            list(self._current_prices)[0].astimezone(REFERENCE_TZ).date()
+            == local_ref_now.date()
+        ):
+            # avoid download of today prices
+            prices = self._current_prices.copy()
+            _LOGGER.debug(
+                "Avoided: %s, with %d prices -> last: %s, download-day: %s",
+                local_ref_now,
+                current_num_prices,
+                list(self._current_prices)[0].astimezone(REFERENCE_TZ).date(),
+                local_ref_now.date(),
+            )
+        else:
+            txt_last = "--"
+            if current_num_prices:
+                txt_last = str(
+                    list(self._current_prices)[-1].astimezone(self._local_timezone)
+                )
+            # make API call to download today prices
+            _LOGGER.debug(
+                "UN-Avoided: %s, with %d prices ->; last:%s, download-day: %s",
+                local_ref_now,
+                current_num_prices,
+                txt_last,
+                local_ref_now.date(),
+            )
+            prices = await self._download_pvpc_prices(local_ref_now.date())
+            if not prices:
+                return prices
 
         # At evening, it is possible to retrieve next day prices
         if local_ref_now.hour >= 20:
@@ -167,7 +258,7 @@ class PVPCData:
                 prices.update(prices_fut)
 
         self._current_prices.update(prices)
-        self._logger.debug(
+        _LOGGER.debug(
             "Download done, now with %d prices from %s UTC",
             len(self._current_prices),
             list(self._current_prices)[0].strftime("%Y-%m-%d %Hh"),
@@ -190,7 +281,7 @@ class PVPCData:
         if utc_now.isoformat() < DATE_CHANGE_TO_20TD.isoformat():
             tariff = self.tariff_old
         attributes: Dict[str, Any] = {"attribution": ATTRIBUTION, "tariff": tariff}
-        utc_time = _ensure_utc_time(utc_now.replace(minute=0, second=0, microsecond=0))
+        utc_time = ensure_utc_time(utc_now.replace(minute=0, second=0, microsecond=0))
         actual_time = utc_time.astimezone(self._local_timezone)
         # todo power_period/power_price €/kW*año
         if len(self._current_prices) > 25 and actual_time.hour < 20:
@@ -241,13 +332,13 @@ class PVPCData:
                 took = monotonic() - tic
                 queue.task_done()
                 if not prices:
-                    self._logger.warning(
+                    _LOGGER.warning(
                         "[%s]: Bad download for day: %s in %.3f s", wk_name, day, took
                     )
                     continue
 
                 downloaded_prices.append((day, prices))
-                self._logger.debug(
+                _LOGGER.debug(
                     "[%s]: Task done for day: %s in %.3f s", wk_name, day, took
                 )
         except asyncio.CancelledError:
@@ -324,7 +415,7 @@ class PVPCData:
             if start_utc <= hour <= end_utc
         }
         if prices:
-            self._logger.warning(
+            _LOGGER.warning(
                 "Download of %d prices from %s to %s in %.2f sec",
                 len(prices),
                 min(prices),
@@ -332,7 +423,7 @@ class PVPCData:
                 monotonic() - tic,
             )
         else:
-            self._logger.error(
+            _LOGGER.error(
                 "BAD Download of PVPC prices from %s to %s in %.2f sec",
                 start,
                 end,
diff --git a/aiopvpc/utils.py b/aiopvpc/utils.py
new file mode 100644
index 0000000..b15d526
--- /dev/null
+++ b/aiopvpc/utils.py
@@ -0,0 +1,12 @@
+from datetime import datetime
+
+from aiopvpc.const import UTC_TZ
+
+
+def ensure_utc_time(ts: datetime) -> datetime:
+    """Return tz-aware datetime in UTC from any datetime."""
+    if ts.tzinfo is None:
+        return datetime(*ts.timetuple()[:6], tzinfo=UTC_TZ)
+    elif str(ts.tzinfo) != str(UTC_TZ):
+        return ts.astimezone(UTC_TZ)
+    return ts
diff --git a/poetry.lock b/poetry.lock
index 819b968..426dd0c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -178,7 +178,7 @@ python-versions = ">=3.6.1"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.0.7"
+version = "2.0.8"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
@@ -224,7 +224,7 @@ test = ["coverage"]
 
 [[package]]
 name = "coverage"
-version = "6.1.2"
+version = "6.2"
 description = "Code coverage measurement for Python"
 category = "dev"
 optional = false
@@ -322,7 +322,7 @@ flake8 = ">=3.5,<5"
 
 [[package]]
 name = "flake8-bugbear"
-version = "21.9.2"
+version = "21.11.29"
 description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
 category = "dev"
 optional = false
@@ -333,7 +333,7 @@ attrs = ">=19.2.0"
 flake8 = ">=3.0.0"
 
 [package.extras]
-dev = ["coverage", "black", "hypothesis", "hypothesmith"]
+dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"]
 
 [[package]]
 name = "flake8-builtins"
@@ -439,7 +439,7 @@ flake8 = ">=3.8.0,<5"
 
 [[package]]
 name = "fonttools"
-version = "4.28.1"
+version = "4.28.2"
 description = "Tools to manipulate font files"
 category = "main"
 optional = true
@@ -552,7 +552,7 @@ test = ["pytest (!=5.3.4)", "pytest-cov", "flaky", "nose", "ipyparallel"]
 
 [[package]]
 name = "ipython"
-version = "7.29.0"
+version = "7.30.0"
 description = "IPython: Productive Interactive Computing"
 category = "main"
 optional = true
@@ -674,7 +674,7 @@ qtconsole = "*"
 
 [[package]]
 name = "jupyter-client"
-version = "7.0.6"
+version = "7.1.0"
 description = "Jupyter protocol implementation and client libraries"
 category = "main"
 optional = true
@@ -1008,7 +1008,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "parso"
-version = "0.8.2"
+version = "0.8.3"
 description = "A Python Parser"
 category = "main"
 optional = true
@@ -1091,7 +1091,7 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pre-commit"
-version = "2.15.0"
+version = "2.16.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 category = "dev"
 optional = false
@@ -1118,7 +1118,7 @@ twisted = ["twisted"]
 
 [[package]]
 name = "prompt-toolkit"
-version = "3.0.22"
+version = "3.0.23"
 description = "Library for building powerful interactive command lines in Python"
 category = "main"
 optional = true
@@ -1333,7 +1333,7 @@ py = {version = "*", markers = "implementation_name == \"pypy\""}
 
 [[package]]
 name = "qtconsole"
-version = "5.2.0"
+version = "5.2.1"
 description = "Jupyter Qt console"
 category = "main"
 optional = true
@@ -1477,7 +1477,7 @@ test = ["pytest"]
 
 [[package]]
 name = "typing-extensions"
-version = "4.0.0"
+version = "4.0.1"
 description = "Backported and Experimental Type Hints for Python 3.6+"
 category = "main"
 optional = false
@@ -1760,8 +1760,8 @@ cfgv = [
     {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
 ]
 charset-normalizer = [
-    {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
-    {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
+    {file = "charset-normalizer-2.0.8.tar.gz", hash = "sha256:735e240d9a8506778cd7a453d97e817e536bb1fc29f4f6961ce297b9c7a917b0"},
+    {file = "charset_normalizer-2.0.8-py3-none-any.whl", hash = "sha256:83fcdeb225499d6344c8f7f34684c2981270beacc32ede2e669e94f7fa544405"},
 ]
 click = [
     {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
@@ -1776,53 +1776,53 @@ convertdate = [
     {file = "convertdate-2.3.2.tar.gz", hash = "sha256:57df962a6047534f1452c390ad48e3dc5c83052ad83ad6dd78d60ae22f99214c"},
 ]
 coverage = [
-    {file = "coverage-6.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:675adb3b3380967806b3cbb9c5b00ceb29b1c472692100a338730c1d3e59c8b9"},
-    {file = "coverage-6.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95a58336aa111af54baa451c33266a8774780242cab3704b7698d5e514840758"},
-    {file = "coverage-6.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0a595a781f8e186580ff8e3352dd4953b1944289bec7705377c80c7e36c4d6c"},
-    {file = "coverage-6.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d3c5f49ce6af61154060640ad3b3281dbc46e2e0ef2fe78414d7f8a324f0b649"},
-    {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:310c40bed6b626fd1f463e5a83dba19a61c4eb74e1ac0d07d454ebbdf9047e9d"},
-    {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a4d48e42e17d3de212f9af44f81ab73b9378a4b2b8413fd708d0d9023f2bbde4"},
-    {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ffa545230ca2ad921ad066bf8fd627e7be43716b6e0fcf8e32af1b8188ccb0ab"},
-    {file = "coverage-6.1.2-cp310-cp310-win32.whl", hash = "sha256:cd2d11a59afa5001ff28073ceca24ae4c506da4355aba30d1e7dd2bd0d2206dc"},
-    {file = "coverage-6.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:96129e41405887a53a9cc564f960d7f853cc63d178f3a182fdd302e4cab2745b"},
-    {file = "coverage-6.1.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1de9c6f5039ee2b1860b7bad2c7bc3651fbeb9368e4c4d93e98a76358cdcb052"},
-    {file = "coverage-6.1.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:80cb70264e9a1d04b519cdba3cd0dc42847bf8e982a4d55c769b9b0ee7cdce1e"},
-    {file = "coverage-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba6125d4e55c0b8e913dad27b22722eac7abdcb1f3eab1bd090eee9105660266"},
-    {file = "coverage-6.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8492d37acdc07a6eac6489f6c1954026f2260a85a4c2bb1e343fe3d35f5ee21a"},
-    {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66af99c7f7b64d050d37e795baadf515b4561124f25aae6e1baa482438ecc388"},
-    {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ebcc03e1acef4ff44f37f3c61df478d6e469a573aa688e5a162f85d7e4c3860d"},
-    {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d44a8136eebbf544ad91fef5bd2b20ef0c9b459c65a833c923d9aa4546b204"},
-    {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:c18725f3cffe96732ef96f3de1939d81215fd6d7d64900dcc4acfe514ea4fcbf"},
-    {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c8e9c4bcaaaa932be581b3d8b88b677489975f845f7714efc8cce77568b6711c"},
-    {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:06d009e8a29483cbc0520665bc46035ffe9ae0e7484a49f9782c2a716e37d0a0"},
-    {file = "coverage-6.1.2-cp36-cp36m-win32.whl", hash = "sha256:e5432d9c329b11c27be45ee5f62cf20a33065d482c8dec1941d6670622a6fb8f"},
-    {file = "coverage-6.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:82fdcb64bf08aa5db881db061d96db102c77397a570fbc112e21c48a4d9cb31b"},
-    {file = "coverage-6.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:94f558f8555e79c48c422045f252ef41eb43becdd945e9c775b45ebfc0cbd78f"},
-    {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046647b96969fda1ae0605f61288635209dd69dcd27ba3ec0bf5148bc157f954"},
-    {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cc799916b618ec9fd00135e576424165691fec4f70d7dc12cfaef09268a2478c"},
-    {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62646d98cf0381ffda301a816d6ac6c35fc97aa81b09c4c52d66a15c4bef9d7c"},
-    {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:27a3df08a855522dfef8b8635f58bab81341b2fb5f447819bc252da3aa4cf44c"},
-    {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:610c0ba11da8de3a753dc4b1f71894f9f9debfdde6559599f303286e70aeb0c2"},
-    {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:35b246ae3a2c042dc8f410c94bcb9754b18179cdb81ff9477a9089dbc9ecc186"},
-    {file = "coverage-6.1.2-cp37-cp37m-win32.whl", hash = "sha256:0cde7d9fe2fb55ff68ebe7fb319ef188e9b88e0a3d1c9c5db7dd829cd93d2193"},
-    {file = "coverage-6.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:958ac66272ff20e63d818627216e3d7412fdf68a2d25787b89a5c6f1eb7fdd93"},
-    {file = "coverage-6.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a300b39c3d5905686c75a369d2a66e68fd01472ea42e16b38c948bd02b29e5bd"},
-    {file = "coverage-6.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3855d5d26292539861f5ced2ed042fc2aa33a12f80e487053aed3bcb6ced13"},
-    {file = "coverage-6.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:586d38dfc7da4a87f5816b203ff06dd7c1bb5b16211ccaa0e9788a8da2b93696"},
-    {file = "coverage-6.1.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a34fccb45f7b2d890183a263578d60a392a1a218fdc12f5bce1477a6a68d4373"},
-    {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bc1ee1318f703bc6c971da700d74466e9b86e0c443eb85983fb2a1bd20447263"},
-    {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3f546f48d5d80a90a266769aa613bc0719cb3e9c2ef3529d53f463996dd15a9d"},
-    {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd92ece726055e80d4e3f01fff3b91f54b18c9c357c48fcf6119e87e2461a091"},
-    {file = "coverage-6.1.2-cp38-cp38-win32.whl", hash = "sha256:24ed38ec86754c4d5a706fbd5b52b057c3df87901a8610d7e5642a08ec07087e"},
-    {file = "coverage-6.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:97ef6e9119bd39d60ef7b9cd5deea2b34869c9f0b9777450a7e3759c1ab09b9b"},
-    {file = "coverage-6.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e5a8c947a2a89c56655ecbb789458a3a8e3b0cbf4c04250331df8f647b3de59"},
-    {file = "coverage-6.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a39590d1e6acf6a3c435c5d233f72f5d43b585f5be834cff1f21fec4afda225"},
-    {file = "coverage-6.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d2c2e3ce7b8cc932a2f918186964bd44de8c84e2f9ef72dc616f5bb8be22e71"},
-    {file = "coverage-6.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3348865798c077c695cae00da0924136bb5cc501f236cfd6b6d9f7a3c94e0ec4"},
-    {file = "coverage-6.1.2-cp39-cp39-win32.whl", hash = "sha256:fae3fe111670e51f1ebbc475823899524e3459ea2db2cb88279bbfb2a0b8a3de"},
-    {file = "coverage-6.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:af45eea024c0e3a25462fade161afab4f0d9d9e0d5a5d53e86149f74f0a35ecc"},
-    {file = "coverage-6.1.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:eab14fdd410500dae50fd14ccc332e65543e7b39f6fc076fe90603a0e5d2f929"},
-    {file = "coverage-6.1.2.tar.gz", hash = "sha256:d9a635114b88c0ab462e0355472d00a180a5fbfd8511e7f18e4ac32652e7d972"},
+    {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"},
+    {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"},
+    {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"},
+    {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"},
+    {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"},
+    {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"},
+    {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"},
+    {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"},
+    {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"},
+    {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"},
+    {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"},
+    {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"},
+    {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"},
+    {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"},
+    {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"},
+    {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"},
+    {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"},
+    {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"},
+    {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"},
+    {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"},
+    {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"},
+    {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"},
+    {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"},
+    {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"},
+    {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"},
+    {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"},
+    {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"},
+    {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"},
+    {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"},
+    {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"},
+    {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"},
+    {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"},
+    {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"},
+    {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"},
+    {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"},
+    {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"},
+    {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"},
+    {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"},
+    {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"},
+    {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"},
+    {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"},
+    {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"},
+    {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"},
+    {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"},
+    {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"},
+    {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"},
+    {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"},
 ]
 cycler = [
     {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"},
@@ -1880,8 +1880,8 @@ flake8-broken-line = [
     {file = "flake8_broken_line-0.4.0-py3-none-any.whl", hash = "sha256:e9c522856862239a2c7ef2c1de0276fa598572aa864bd4e9c7efc2a827538515"},
 ]
 flake8-bugbear = [
-    {file = "flake8-bugbear-21.9.2.tar.gz", hash = "sha256:db9a09893a6c649a197f5350755100bb1dd84f110e60cf532fdfa07e41808ab2"},
-    {file = "flake8_bugbear-21.9.2-py36.py37.py38-none-any.whl", hash = "sha256:4f7eaa6f05b7d7ea4cbbde93f7bcdc5438e79320fa1ec420d860c181af38b769"},
+    {file = "flake8-bugbear-21.11.29.tar.gz", hash = "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"},
+    {file = "flake8_bugbear-21.11.29-py36.py37.py38-none-any.whl", hash = "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82"},
 ]
 flake8-builtins = [
     {file = "flake8-builtins-1.5.3.tar.gz", hash = "sha256:09998853b2405e98e61d2ff3027c47033adbdc17f9fe44ca58443d876eb00f3b"},
@@ -1919,8 +1919,8 @@ flake8-tidy-imports = [
     {file = "flake8_tidy_imports-4.5.0-py3-none-any.whl", hash = "sha256:87eed94ae6a2fda6a5918d109746feadf1311e0eb8274ab7a7920f6db00a41c9"},
 ]
 fonttools = [
-    {file = "fonttools-4.28.1-py3-none-any.whl", hash = "sha256:68071406009e7ef6a5fdcd85d95975cd6963867bb226f2b786bfffe15d1959ef"},
-    {file = "fonttools-4.28.1.zip", hash = "sha256:8c8f84131bf04f3b1dcf99b9763cec35c347164ab6ad006e18d2f99fcab05529"},
+    {file = "fonttools-4.28.2-py3-none-any.whl", hash = "sha256:eff1da7ea274c54cb8842853005a139f711646cbf6f1bcfb6c9b86a627f35ff0"},
+    {file = "fonttools-4.28.2.zip", hash = "sha256:dca694331af74c8ad47acc5171e57f6b78fac5692bf050f2ab572964577ac0dd"},
 ]
 frozenlist = [
     {file = "frozenlist-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:977a1438d0e0d96573fd679d291a1542097ea9f4918a8b6494b06610dfeefbf9"},
@@ -2025,8 +2025,8 @@ ipykernel = [
     {file = "ipykernel-6.5.1.tar.gz", hash = "sha256:dd27172bccbbcfef952991e49372e4c6fd1c14eed0df05ebd5b4335cb27a81a2"},
 ]
 ipython = [
-    {file = "ipython-7.29.0-py3-none-any.whl", hash = "sha256:a658beaf856ce46bc453366d5dc6b2ddc6c481efd3540cb28aa3943819caac9f"},
-    {file = "ipython-7.29.0.tar.gz", hash = "sha256:4f69d7423a5a1972f6347ff233e38bbf4df6a150ef20fbb00c635442ac3060aa"},
+    {file = "ipython-7.30.0-py3-none-any.whl", hash = "sha256:c8f3e07aefb9cf9e067f39686f035ce09b27a1ee602116a3030b91b6fc138ee4"},
+    {file = "ipython-7.30.0.tar.gz", hash = "sha256:d41f8e80b99690122400f9b2069b12f670246a1b4cc5d332bd6c4e2500e6d6fb"},
 ]
 ipython-genutils = [
     {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
@@ -2054,8 +2054,8 @@ jupyter = [
     {file = "jupyter-1.0.0.zip", hash = "sha256:3e1f86076bbb7c8c207829390305a2b1fe836d471ed54be66a3b8c41e7f46cc7"},
 ]
 jupyter-client = [
-    {file = "jupyter_client-7.0.6-py3-none-any.whl", hash = "sha256:074bdeb1ffaef4a3095468ee16313938cfdc48fc65ca95cc18980b956c2e5d79"},
-    {file = "jupyter_client-7.0.6.tar.gz", hash = "sha256:8b6e06000eb9399775e0a55c52df6c1be4766666209c22f90c2691ded0e338dc"},
+    {file = "jupyter_client-7.1.0-py3-none-any.whl", hash = "sha256:64d93752d8cbfba0c1030c3335c3f0d9797cd1efac012652a14aac1653db11a3"},
+    {file = "jupyter_client-7.1.0.tar.gz", hash = "sha256:a5f995a73cffb314ed262713ae6dfce53c6b8216cea9f332071b8ff44a6e1654"},
 ]
 jupyter-console = [
     {file = "jupyter_console-6.4.0-py3-none-any.whl", hash = "sha256:7799c4ea951e0e96ba8260575423cb323ea5a03fcf5503560fa3e15748869e27"},
@@ -2417,8 +2417,8 @@ pandocfilters = [
     {file = "pandocfilters-1.5.0.tar.gz", hash = "sha256:0b679503337d233b4339a817bfc8c50064e2eff681314376a47cb582305a7a38"},
 ]
 parso = [
-    {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"},
-    {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"},
+    {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"},
+    {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"},
 ]
 pathspec = [
     {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
@@ -2488,16 +2488,16 @@ pluggy = [
     {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
 ]
 pre-commit = [
-    {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"},
-    {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"},
+    {file = "pre_commit-2.16.0-py2.py3-none-any.whl", hash = "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e"},
+    {file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"},
 ]
 prometheus-client = [
     {file = "prometheus_client-0.12.0-py2.py3-none-any.whl", hash = "sha256:317453ebabff0a1b02df7f708efbab21e3489e7072b61cb6957230dd004a0af0"},
     {file = "prometheus_client-0.12.0.tar.gz", hash = "sha256:1b12ba48cee33b9b0b9de64a1047cbd3c5f2d0ab6ebcead7ddda613a750ec3c5"},
 ]
 prompt-toolkit = [
-    {file = "prompt_toolkit-3.0.22-py3-none-any.whl", hash = "sha256:48d85cdca8b6c4f16480c7ce03fd193666b62b0a21667ca56b4bb5ad679d1170"},
-    {file = "prompt_toolkit-3.0.22.tar.gz", hash = "sha256:449f333dd120bd01f5d296a8ce1452114ba3a71fae7288d2f0ae2c918764fa72"},
+    {file = "prompt_toolkit-3.0.23-py3-none-any.whl", hash = "sha256:5f29d62cb7a0ecacfa3d8ceea05a63cd22500543472d64298fc06ddda906b25d"},
+    {file = "prompt_toolkit-3.0.23.tar.gz", hash = "sha256:7053aba00895473cb357819358ef33f11aa97e4ac83d38efb123e5649ceeecaf"},
 ]
 ptyprocess = [
     {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
@@ -2685,8 +2685,8 @@ pyzmq = [
     {file = "pyzmq-22.3.0.tar.gz", hash = "sha256:8eddc033e716f8c91c6a2112f0a8ebc5e00532b4a6ae1eb0ccc48e027f9c671c"},
 ]
 qtconsole = [
-    {file = "qtconsole-5.2.0-py3-none-any.whl", hash = "sha256:a287f9f0f7365ccb2f2a88e0cd4da883822e94d15b75dc19098cd8eec44d70d1"},
-    {file = "qtconsole-5.2.0.tar.gz", hash = "sha256:6bb4df839609f240194213407872076f871e3a3884cf8e785068e8c7f39344c6"},
+    {file = "qtconsole-5.2.1-py3-none-any.whl", hash = "sha256:37317e9ee7faa2faaa927b09a6ec24a45cd7b19c9975d97ab6fd8985354839f1"},
+    {file = "qtconsole-5.2.1.tar.gz", hash = "sha256:bb3b9f0d674055e627c1097779c0d5e028176706d3b6be39cf52235f6ddcc88e"},
 ]
 qtpy = [
     {file = "QtPy-1.11.2-py2.py3-none-any.whl", hash = "sha256:83c502973e9fdd7b648d8267a421229ea3d9a0651c22e4c65a4d9228479c39b6"},
@@ -2822,8 +2822,8 @@ traitlets = [
     {file = "traitlets-5.1.1.tar.gz", hash = "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"},
-    {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"},
+    {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"},
+    {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"},
 ]
 virtualenv = [
     {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"},
diff --git a/pyproject.toml b/pyproject.toml
index be7ce30..8d2b204 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,7 +40,7 @@ markers = [
 
 [tool.poetry]
 name = "aiopvpc"
-version = "2.2.4"
+version = "2.3.0"
 description = "Retrieval of Spanish Electricity hourly prices (PVPC)"
 authors = ["Eugenio Panadero <eugenio.panadero@gmail.com>"]
 license = "MIT"


Test patch:
diff --git a/tests/test_pvpc.py b/tests/test_pvpc.py
index 2f0850d..ba34c5a 100644
--- a/tests/test_pvpc.py
+++ b/tests/test_pvpc.py
@@ -7,7 +7,7 @@ from unittest.mock import patch
 import pytest
 from aiohttp import ClientError
 
-from aiopvpc.const import OLD_TARIFS_IDS, REFERENCE_TZ
+from aiopvpc.const import OLD_TARIFS_IDS, REFERENCE_TZ, UTC_TZ
 from aiopvpc.pvpc_data import PVPCData
 from tests.conftest import MockAsyncSession, TZ_TEST
 
@@ -177,3 +177,81 @@ async def test_download_range(caplog):
     # Check only tariff values are retrieved
     assert isinstance(prices[first_price], float)
     assert prices[first_price] < 1
+
+
+async def _run_h_step(
+    mock_session: MockAsyncSession, pvpc_data: PVPCData, start: datetime
+):
+    if pvpc_data._current_prices:
+        logging.debug(
+            "[Calls=%d]-> start=%s --> %s -> %s (%d prices)",
+            mock_session.call_count,
+            start,
+            list(pvpc_data._current_prices)[0].strftime("%Y-%m-%d %Hh"),
+            list(pvpc_data._current_prices)[-1].strftime("%Y-%m-%d %Hh"),
+            len(pvpc_data._current_prices),
+        )
+    await pvpc_data.async_update_prices(start)
+    assert pvpc_data.process_state_and_attributes(start)
+    start += timedelta(hours=1)
+    return start, pvpc_data._current_prices
+
+
+# TODO review download schedule for Canary Islands TZ
+@pytest.mark.parametrize("local_tz", (TZ_TEST, REFERENCE_TZ))
+@pytest.mark.asyncio
+async def test_reduced_api_download_rate(local_tz):
+    """Test time evolution and number of API calls."""
+    start = datetime(2019, 10, 26, 15, tzinfo=UTC_TZ)
+    mock_session = MockAsyncSession()
+    # logging.critical(local_tz)
+    pvpc_data = PVPCData(
+        tariff="electric_car", local_timezone=local_tz, websession=mock_session
+    )
+
+    # avoid extra calls at day if already got all today prices
+    for _ in range(3):
+        start, prices = await _run_h_step(mock_session, pvpc_data, start)
+        assert mock_session.call_count == 1
+        assert len(prices) == 24
+
+    # first call for next-day prices
+    assert start == datetime(2019, 10, 26, 18, tzinfo=UTC_TZ)
+    start, prices = await _run_h_step(mock_session, pvpc_data, start)
+    assert mock_session.call_count == 2
+    assert len(prices) == 49
+
+    # avoid calls at evening if already got all today+tomorrow prices
+    for _ in range(3):
+        start, prices = await _run_h_step(mock_session, pvpc_data, start)
+        assert mock_session.call_count == 2
+        assert len(prices) == 49
+
+    # avoid calls at day if already got all today prices
+    for _ in range(21):
+        start, prices = await _run_h_step(mock_session, pvpc_data, start)
+        assert mock_session.call_count == 2
+        assert pvpc_data.state_available
+        # assert len(prices) == 25
+
+    # call for next-day prices (no more available)
+    assert start == datetime(2019, 10, 27, 19, tzinfo=UTC_TZ)
+    call_count = mock_session.call_count
+    while start.astimezone(local_tz) <= datetime(2019, 10, 27, 23, tzinfo=local_tz):
+        start, prices = await _run_h_step(mock_session, pvpc_data, start)
+        call_count += 1
+        assert mock_session.call_count == call_count
+        # assert len(prices) == 25
+
+    # assert mock_session.call_count == 6
+    assert pvpc_data.state
+    assert pvpc_data.state_available
+    assert start.astimezone(local_tz) == datetime(2019, 10, 28, tzinfo=local_tz)
+    assert not pvpc_data.process_state_and_attributes(start)
+
+    # After known prices are exausted, the state is flagged as unavailable
+    with pytest.raises(AssertionError):
+        await _run_h_step(mock_session, pvpc_data, start)
+    assert not pvpc_data.state_available
+    start += timedelta(hours=1)
+    assert not pvpc_data.process_state_and_attributes(start)