Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pvpc electricity prices integration #32092

Merged
merged 34 commits into from Mar 22, 2020

Conversation

azogue
Copy link
Member

@azogue azogue commented Feb 22, 2020

I've been using a custom_component sensor for the hourly price of electricity in Spain for almost 2 years, but after seeing it as a feature request in the forum, I decided to clean and update it up to current standards to propose it as new integration: pvpc_hourly_pricing

Description

  • Cloud polling sensor that downloads a JSON file provided by the official API of the electric network operator in Spain (REE) to extract the 'PVPC' prices for each hour of the day, in €/kWh.
  • The configuration is done by selecting one of the 3 reference tariffs in Spain, with
    1, 2, or 3 billing periods for each one.
  • Features config flow, entity registry, RestoreEntity, and options flow to change tariff
  • Manual yaml config can be done as sensor platform entity or by component. (if some of these are deprecated there is no problem to remove those, I just wanted to cover all cases first)
  • Smart logging & sensor availability: if no internet connection, just 1 warning log, and another one when recovered.
  • Minimal polling, with 2 API calls/hour at random times with smart retry; fully async
  • Only 1 state change / hour (only when the price changes)
  • In evening, downloads published prices for next day, to always store prices info for a window of [3, 27] hours in the future.
  • Include useful state attributes to program automations to be run at best electric prices.
  • Add spanish and english translations.

Breaking change

Proposed change

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Example entry for configuration.yaml:

The simplest way of using it is to add it from "Integrations" (can be used up to 3 times to set sensors with different tariffs), but there is also the possibility of manual yaml setup, by component entities.

# Example configuration.yaml as component
pvpc_hourly_pricing:
  - name: PVPC manual ve
    tariff: coche_electrico
  - name: PVPC manual nocturna
    tariff: discriminacion

Additional information

Checklist

  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • The code has been formatted using Black (black --fast homeassistant tests)
  • Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • Untested files have been added to .coveragerc.

The integration reached or maintains the following Integration Quality Scale:

  • No score or internal
  • 🥈 Silver
  • 🥇 Gold
  • 🏆 Platinum

I hope!
If not, there is no problem to change it until it reaches platinum score :)

@codecov
Copy link

codecov bot commented Feb 22, 2020

Codecov Report

❗ No coverage uploaded for pull request base (dev@86d48c6). Click here to learn what that means.
The diff coverage is 100%.

Impacted file tree graph

@@          Coverage Diff           @@
##             dev   #32092   +/-   ##
======================================
  Coverage       ?   94.76%           
======================================
  Files          ?      770           
  Lines          ?    55686           
  Branches       ?        0           
======================================
  Hits           ?    52769           
  Misses         ?     2917           
  Partials       ?        0
Impacted Files Coverage Δ
homeassistant/generated/config_flows.py 100% <ø> (ø)
...assistant/components/pvpc_hourly_pricing/sensor.py 100% <100%> (ø)
...eassistant/components/pvpc_hourly_pricing/const.py 100% <100%> (ø)
...tant/components/pvpc_hourly_pricing/config_flow.py 100% <100%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 86d48c6...2b53ee0. Read the comment docs.

@springstan springstan changed the title New integration: hourly price of electricity in Spain Add pvpc prices integration Feb 22, 2020
@springstan springstan changed the title Add pvpc prices integration Add pvpc electricity prices integration Feb 22, 2020
@azogue azogue force-pushed the feature/electric-prices-spain branch from c484d39 to 9eadfc2 Compare March 6, 2020 12:56
Dev automation moved this from Needs review to Review in progress Mar 16, 2020
Copy link
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@azogue Thanks for the integration. Electricity prices in Spain certainly warrant some automation actions.

@azogue
Copy link
Member Author

azogue commented Mar 16, 2020

@azogue Thanks for the integration. Electricity prices in Spain certainly warrant some automation actions.

Thanks to you for reviewing it, @bdraco :)

About the pypi policy, I knew about it, but for this time I didn't consider the possibility, as:

  • it is just getting a JSON file from a fixed URL that points to a specific official file.
  • To get it it is using the shared websession from HA, and also we access the hass.loop for the async_timeout.
  • To program the retry if the call fails, it is using async_track_point_in_time to repeat it after some time,
  • and it is handling different exceptions for the call, and logging errors depending on HA availability concept,

So I thought about the possibility of moving out some logic, but these reasons made me avoid it, as the external package would be just an async method with a lot of arguments, like this:

async def download_pvpc_prices(
    day: date,
    tariff: str,
    loop: asyncio.AbstractEventLoop,
    websession: aiohttp.ClientSession,
    logger: logging.Logger,
    timeout: float = 5,
    data_source_available: bool = True,
) -> Dict[datetime, float]:
    """
    PVPC data extractor.

    Make GET request to 'api.esios.ree.es' and extract hourly prices for 
    the selected tariff from the JSON daily file download
    of the official _Spain Electric Network_ (Red Eléctrica Española, REE)
    for the _Voluntary Price for Small Consumers_
    (Precio Voluntario para el Pequeño Consumidor, PVPC).

    Prices are referenced with datetimes in UTC.
    """
    url = _RESOURCE.format(day=day)
    key = _TARIFF_KEYS[tariff]
    try:
        with async_timeout.timeout(timeout, loop=loop):
            resp = await websession.get(url)
            if resp.status < 400:
                data = await resp.json()
                ts_init = (
                    datetime.strptime(data["PVPC"][0]["Dia"], "%d/%m/%Y")
                    .astimezone(_REFERENCE_TZ)
                    .astimezone(UTC)
                )
                return {
                    ts_init
                    + timedelta(hours=i): round(
                        float(values_hour[key].replace(",", ".")) / 1000.0,
                        _PRECISION
                    )
                    for i, values_hour in enumerate(data["PVPC"])
                }
    except KeyError:
        logger.debug("Bad try on getting prices for %s", day)
    except asyncio.TimeoutError:  # pragma: no cover
        if data_source_available:
            logger.warning("Timeout error requesting data from '%s'", url)
    except aiohttp.ClientError:  # pragma: no cover
        if data_source_available:
            logger.warning("Client error in '%s'", url)
    return {}

So I discarded the idea. But if it is strictly necessary I have no problems doing it. So, what's your advice? Should I move the method above to its own aiopvpc library?
Should it be bigger? like a class for a data handler also generating the state and attributes there?

Another big doubt is with tests.

As the extraction logic is here, I added some fixture files to test the availability logic (HA specific) and also the special DST days when the prices array has 23 or 25 values.
Those are exactly the files the official API is providing. If I externalize the download logic, it's not clear for me what should I mock (I suppose the logic in tests would increase, as I would have to write down the dicts with utc_datetime: price pairs we are obtaining from the JSON files.

@bdraco
Copy link
Member

bdraco commented Mar 16, 2020

@azogue Thanks for the integration. Electricity prices in Spain certainly warrant some automation actions.

Thanks to you for reviewing it, @bdraco :)

About the pypi policy, I knew about it, but for this time I didn't consider the possibility, as:

  • it is just getting a JSON file from a fixed URL that points to a specific official file.
  • To get it it is using the shared websession from HA, and also we access the hass.loop for the async_timeout.

You can pass the websession to your pypi module.

  • To program the retry if the call fails, it is using async_track_point_in_time to repeat it after some time,

It likely makes sense to use DataUpdateCoordinator
https://developers.home-assistant.io/docs/integration_fetching_data/

  • and it is handling different exceptions for the call, and logging errors depending on HA availability concept,

So I thought about the possibility of moving out some logic, but these reasons made me avoid it, as the external package would be just an async method with a lot of arguments, like this:

async def download_pvpc_prices(
    day: date,
    tariff: str,
    loop: asyncio.AbstractEventLoop,
    websession: aiohttp.ClientSession,
    logger: logging.Logger,
    timeout: float = 5,
    data_source_available: bool = True,
) -> Dict[datetime, float]:

Ideally, the module provides a class where you pass in the hass.helpers.aiohttp_client.async_get_clientsession()

Then you only have to pass the most minimal amount to your data fetch function download_pvpc_prices

If you make an additional class .. maybe something like

class PvPcPrice:

You can do something like

  pvpcprice_data = PvPcPriceData(json_data)

This would make it easier to mock in the testing as well as you could pass your fixture though PvPcPriceData.

"""
PVPC data extractor.

Make GET request to 'api.esios.ree.es' and extract hourly prices for 
the selected tariff from the JSON daily file download
of the official _Spain Electric Network_ (Red Eléctrica Española, REE)
for the _Voluntary Price for Small Consumers_
(Precio Voluntario para el Pequeño Consumidor, PVPC).

Prices are referenced with datetimes in UTC.
"""
url = _RESOURCE.format(day=day)
key = _TARIFF_KEYS[tariff]
try:
    with async_timeout.timeout(timeout, loop=loop):
        resp = await websession.get(url)
        if resp.status < 400:
            data = await resp.json()
            ts_init = (
                datetime.strptime(data["PVPC"][0]["Dia"], "%d/%m/%Y")
                .astimezone(_REFERENCE_TZ)
                .astimezone(UTC)
            )
            return {
                ts_init
                + timedelta(hours=i): round(
                    float(values_hour[key].replace(",", ".")) / 1000.0,
                    _PRECISION
                )
                for i, values_hour in enumerate(data["PVPC"])
            }
except KeyError:
    logger.debug("Bad try on getting prices for %s", day)

Its ok to trap the exceptions in HomeAssistant so we raise ConfigEntryNotReady when we get timeouts.

except asyncio.TimeoutError:  # pragma: no cover
    if data_source_available:
        logger.warning("Timeout error requesting data from '%s'", url)
except aiohttp.ClientError:  # pragma: no cover
    if data_source_available:
        logger.warning("Client error in '%s'", url)
return {}

So I discarded the idea. But if it is strictly necessary I have no problems doing it. So, what's your advice? Should I move the method above to its own _`aiopvpc`_ library?

Absolutely. If there are changes in the API, we only have to bump the library instead of going through the PR process. This makes it far more maintainable.

Should it be bigger? like a class for a data handler also generating the state and attributes there?

Another big doubt is with tests.

As the extraction logic is here, I added some fixture files to test the availability logic (HA specific) and also the special DST days when the prices array has 23 or 25 values.
Those are exactly the files the official API is providing. If I externalize the download logic, it's not clear for me what should I mock (I suppose the logic in tests would increase, as I would have to write down the dicts with utc_datetime: price pairs we are obtaining from the JSON files.

@azogue
Copy link
Member Author

azogue commented Mar 16, 2020

Hi @bdraco,

I'm already on it, and making a PVPCData handler to solve all specific logic, generate attributes, etc. The DataUpdateCoordinator is not necessary here, as we have de-coupled the state update (selecting the price for current hour) and the data fetch (downloading the data), and the former is done with just 2 calls/hour.

Tests could be highly reduced, as all logic is externalized now.

I'll push the changes the moment I publish the new repo. If it is not sufficient, I'll move to the coordinator.

Thanks again for taking the time to review.

@azogue
Copy link
Member Author

azogue commented Mar 16, 2020

Now I remember why I discarded the coordinator: it works for the usual periodic fetching with a scan_interval, using async_track_time_interval. Here we don't want to update state each X ∆T, we need to update it at exact hours, when the price changes. Also, the data fetch is made with very low frequency (and could be even lower) to no stress the API (which is a lazy one), so I didn't think that the UDC in its current form was a good solution for this.

@bdraco
Copy link
Member

bdraco commented Mar 16, 2020

Hi @bdraco,

I'm already on it, and making a PVPCData handler to solve all specific logic, generate attributes, etc. The DataUpdateCoordinator is not necessary here, as we have de-coupled the state update (selecting the price for current hour) and the data fetch (downloading the data), and the former is done with just 2 calls/hour.

Tests could be highly reduced, as all logic is externalized now.

I'll push the changes the moment I publish the new repo. If it is not sufficient, I'll move to the coordinator.

Thanks again for taking the time to review.

No need to use the coordinator if it's not the right fit for what you are doing.

to add a sensor with the current hourly price of electricity in Spain.
Configuration is done by selecting one of the 3 reference tariffs, with
1, 2, or 3 billing periods.

* Features config flow, entity registry, RestoreEntity, options flow
  to change tariff, manual yaml config as integration or sensor platform
* Cloud polling sensor with minimal API calls (3/hour at random times)
  and smart retry; fully async
* Only 1 state change / hour (only when the price changes)
* At evening, try to download published tomorrow prices, to always store
  prices info for a window of [3, 27] hours in the future.
* Include useful state attributes to program automations to be run
  at best electric prices.
* Add spanish and english translations.

* Requires `xmltodict` to parse official xml file with hourly prices
for each day.
Instead, create time change listeners in async_added_to_hass and
call async_generate_entity_id before async_add_entities
with entity definition as integration and also as a sensor platform
- to deal with days with DST changes
- and work with different local timezones
@bdraco
Copy link
Member

bdraco commented Mar 19, 2020

Ready for another review pass or still in progress ?

@azogue
Copy link
Member Author

azogue commented Mar 19, 2020

Ready for another review pass or still in progress ?

Ready!

Copy link
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@azogue This is really good. There are some adjustments to make with unique ids and it should be good to go after that

homeassistant/components/pvpc_hourly_pricing/sensor.py Outdated Show resolved Hide resolved
homeassistant/components/pvpc_hourly_pricing/sensor.py Outdated Show resolved Hide resolved
- Remove PLATFORM_SCHEMA and async_setup_platform
- Generate config_entry.unique_id with tariff instead of entity_id, in flow step.
- Remove TariffSelectorConfigFlow
- Adapt tests to maintain full coverage
and rename SENSOR_SCHEMA to SINGLE_SENSOR_SCHEMA to avoid confusion
@bdraco bdraco self-requested a review March 19, 2020 22:14
Copy link
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there! Keep up the good work

- No need for a test_setup now, as platform setup is removed and integration
  setup is already used in `test_availability`
- Simplified `_process_time_step`: only one async_fire(EVENT_TIME_CHANGED)/hour
Copy link
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting closer!

@azogue
Copy link
Member Author

azogue commented Mar 21, 2020

Hi @bdraco, you can check it again :)

Copy link
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is getting closer to merge, would you please recheck the docs and make sure they are still accurate.

@azogue
Copy link
Member Author

azogue commented Mar 21, 2020

Hi @bdraco

As this is getting closer to merge, would you please recheck the docs and make sure they are still accurate.

I've been updating them during the review, removing references to:

  • Options and change of tariff
  • Setup as platform sensor
  • Custom timeout

So I think they're up to date. Are you thinking about something specific?

and call to async_update after 1st download or when recovering access, so
async_write_ha_state is not called twice on those.
@bdraco
Copy link
Member

bdraco commented Mar 22, 2020

Looks good, retesting now

@bdraco
Copy link
Member

bdraco commented Mar 22, 2020

Screen Shot 2020-03-22 at 2 23 27 PM

@bdraco bdraco merged commit 8d2e72c into home-assistant:dev Mar 22, 2020
Dev automation moved this from Review in progress to Done Mar 22, 2020
Dev automation moved this from Done to Reviewer approved Mar 22, 2020
@azogue
Copy link
Member Author

azogue commented Mar 22, 2020

Hi @bdraco, your strange attributes are because of your timezone :)

As the hourly prices are downloaded in daily packets localized on Europe/Madrid timezone, when they get re-localized the local hours drift.

At evening, prices for the next day are published and the integration downloads them, so at night, this is how it looks:

Captura de pantalla 2020-03-22 a las 21 00 34

The only other existing timezone for the locations where the prices apply is 'Atlantic/Canary', for the Canary Islands, which are 1 hour behind the peninsular timezone. For them only one "price last day 23h" appears, corresponding to the 00:00 hour for the main timezone.

@azogue
Copy link
Member Author

azogue commented Mar 23, 2020

Hi @MartinHjelmare, I'll do a small PR asap to address your comments, ok?
Thanks for reviewing it :)

@lock lock bot locked and limited conversation to collaborators Mar 27, 2020
@springstan springstan moved this from Reviewer approved to Done in Dev Mar 27, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
Dev
  
Done
Development

Successfully merging this pull request may close these issues.

None yet

5 participants