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 special behaviour for 'composed' price sensors #70

Merged
merged 7 commits into from
Mar 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@ on:
jobs:
test-and-publish-flow:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Set up python
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: 3.9
python-version: 3.11

- name: Run Poetry image
uses: abatilo/actions-poetry@v2.0.0
with:
poetry-version: 1.3.1
poetry-version: 1.7.1

- name: Install library
run: poetry install --no-interaction
Expand All @@ -43,8 +45,9 @@ jobs:
- 'pyproject.toml'

- if: ${{ (github.ref == 'refs/heads/master') && (steps.changes.outputs.root == 'true')}}
name: Build and publish
env:
PYPI_USER: ${{ secrets.PYPI_USER }}
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: poetry publish --username $PYPI_USER --password $PYPI_PASSWORD --build
name: Build
run: poetry build

- if: ${{ (github.ref == 'refs/heads/master') && (steps.changes.outputs.root == 'true')}}
name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
21 changes: 5 additions & 16 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
minimum_pre_commit_version: "2.10.0"
minimum_pre_commit_version: "3.0.4"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
rev: v0.3.2
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
args:
- --dont-order-by-type
- repo: https://github.com/psf/black
rev: "24.1.1"
hooks:
- id: black
name: Format code (black)
- repo: https://github.com/pre-commit/pre-commit-hooks
- id: ruff-format
- repo: "https://github.com/pre-commit/pre-commit-hooks"
rev: "v4.5.0"
hooks:
- id: end-of-file-fixer
Expand All @@ -31,7 +20,7 @@ repos:
hooks:
- id: prettier
- repo: "https://github.com/pre-commit/mirrors-mypy"
rev: "v1.8.0"
rev: "v1.9.0"
hooks:
- id: "mypy"
name: "Check type hints (mypy)"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [v4.3.0](https://github.com/azogue/aiopvpc/tree/v4.3.0) - ✨ Add new _composed_ sensor for Indexed tariff (2024-03-10)

[Full Changelog](https://github.com/azogue/aiopvpc/compare/v4.2.2...v4.3.0)

- ✨ Add new price sensors: **Market adjustment** (from ESIOS API indicator 2108), and **Indexed tariff** (as _composed_ price sensor calculated as `PVPC - ADJUSTMENT`), from first-time contributor @MiguelAngelLV in #69 🍻, and some adjustments in #70
- 🎨 pre-commit updates and change to `ruff` + `ruff-format` instead of `black` and `isort`
- 🚀 Bump minor version and update deps and CHANGELOG.md

## [v4.2.2](https://github.com/azogue/aiopvpc/tree/v4.2.2) - ♻️ Remove python upper limit (2023-07-30)

[Full Changelog](https://github.com/azogue/aiopvpc/compare/v4.2.1...v4.2.2)
Expand Down
14 changes: 11 additions & 3 deletions aiopvpc/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@
ESIOS_MAG = "1900" # regargo GAS
ESIOS_OMIE = "10211" # precio mayorista
ESIOS_MARKET_ADJUSTMENT = "2108" # ajuste mercado Indexada VS PVPC
ESIOS_INDEXED = "0" # precio indexado restando PVPC y el ajuste

# unique ids for each series
KEY_PVPC = "PVPC"
KEY_INJECTION = "INJECTION"
KEY_MAG = "MAG" # regargo GAS
KEY_OMIE = "OMIE" # precio mayorista
KEY_ADJUSTMENT = "ADJUSTMENT" # ajuste mercado
KEY_INDEXED = "INDEXED" # precio indexada
# composed sensors
KEY_INDEXED = "INDEXED" # precio indexada (:= PVPC - ADJUSTMENT)

ALL_SENSORS = (KEY_PVPC, KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_ADJUSTMENT)
SENSOR_KEY_TO_DATAID = {
Expand All @@ -71,7 +71,6 @@
KEY_MAG: ESIOS_MAG,
KEY_OMIE: ESIOS_OMIE,
KEY_ADJUSTMENT: ESIOS_MARKET_ADJUSTMENT,
KEY_INDEXED: ESIOS_MARKET_ADJUSTMENT,
}
SENSOR_KEY_TO_NAME = {
KEY_PVPC: "PVPC T. 2.0TD",
Expand All @@ -81,6 +80,15 @@
KEY_ADJUSTMENT: "Ajuste de mercado a plazo",
KEY_INDEXED: "Precio tarifa Indexada",
}
# API indicator dependencies for each price sensor
SENSOR_KEY_TO_API_SERIES = {
KEY_PVPC: [KEY_PVPC],
KEY_INJECTION: [KEY_INJECTION],
KEY_MAG: [KEY_MAG],
KEY_OMIE: [KEY_OMIE],
KEY_ADJUSTMENT: [KEY_ADJUSTMENT],
KEY_INDEXED: [KEY_PVPC, KEY_ADJUSTMENT],
}


@dataclass
Expand Down
40 changes: 31 additions & 9 deletions aiopvpc/prices.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""ESIOS API handler for HomeAssistant. Hourly price attributes."""

import zoneinfo
from contextlib import suppress
from datetime import datetime
from typing import Any

from .const import KEY_INJECTION
from .const import EsiosApiData, KEY_ADJUSTMENT, KEY_INDEXED, KEY_INJECTION, KEY_PVPC


def _is_tomorrow_price(ts: datetime, ref: datetime) -> bool:
return any(map(lambda x: x[0] > x[1], zip(ts.isocalendar(), ref.isocalendar())))
return any(
ts_comp > ts_tz_ref
for ts_comp, ts_tz_ref in zip(ts.isocalendar(), ref.isocalendar())
)


def _split_today_tomorrow_prices(
Expand Down Expand Up @@ -65,29 +69,26 @@ def _make_price_stats_attributes(
attributes["hours_to_better_price"] = int(delta_better.total_seconds()) // 3600
attributes["num_better_prices_ahead"] = len(better_prices_ahead)

try:
with suppress(ValueError):
attributes["price_position"] = (
list(prices_sorted.values()).index(current_price) + 1
)
except ValueError:
pass

max_price = max(current_prices.values())
min_price = min(current_prices.values())
try:
with suppress(ZeroDivisionError):
attributes["price_ratio"] = round(
(current_price - min_price) / (max_price - min_price), 2
)
except ZeroDivisionError: # pragma: no cover
pass

attributes["max_price"] = max_price
first_price_at = next(iter(prices_sorted)).astimezone(timezone).hour
last_price_at = next(iter(reversed(prices_sorted))).astimezone(timezone).hour
attributes["max_price_at"] = last_price_at if sign_is_best == 1 else first_price_at
attributes["min_price"] = min_price
attributes["min_price_at"] = first_price_at if sign_is_best == 1 else last_price_at
attributes["next_best_at"] = [
ts.astimezone(timezone).hour for ts in prices_sorted.keys() if ts >= utc_time
ts.astimezone(timezone).hour for ts in prices_sorted if ts >= utc_time
]
return attributes

Expand Down Expand Up @@ -116,3 +117,24 @@ def make_price_sensor_attributes(
price_attrs = {**price_attrs, **tomorrow_prices}
price_tags = {**price_tags, **tomorrow_price_tags}
return {**price_attrs, **price_tags}


def add_composed_price_sensors(data: EsiosApiData):
"""Calculate price sensors derived from multiple data series."""
if (
data.availability.get(KEY_PVPC, False)
and data.availability.get(KEY_ADJUSTMENT, False)
and (
common_ts_prices := set(data.sensors[KEY_PVPC]).intersection(
set(data.sensors[KEY_ADJUSTMENT])
)
)
):
# generate 'indexed tariff' as: PRICE = PVPC - ADJUSTMENT
pvpc = data.sensors[KEY_PVPC]
adjustment = data.sensors[KEY_ADJUSTMENT]
data.sensors[KEY_INDEXED] = {
ts_hour: round(pvpc[ts_hour] - adjustment[ts_hour], 5)
for ts_hour in common_ts_prices
}
data.availability[KEY_INDEXED] = True
45 changes: 17 additions & 28 deletions aiopvpc/pvpc_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,16 @@
DEFAULT_TIMEOUT,
EsiosApiData,
EsiosResponse,
KEY_ADJUSTMENT,
KEY_INDEXED,
KEY_PVPC,
REFERENCE_TZ,
SENSOR_KEY_TO_API_SERIES,
SENSOR_KEY_TO_DATAID,
TARIFFS,
UTC_TZ,
zoneinfo,
)
from aiopvpc.parser import extract_esios_data, get_daily_urls_to_download
from aiopvpc.prices import make_price_sensor_attributes
from aiopvpc.prices import add_composed_price_sensors, make_price_sensor_attributes
from aiopvpc.pvpc_tariff import get_current_and_next_tariff_periods
from aiopvpc.utils import ensure_utc_time

Expand Down Expand Up @@ -107,7 +106,7 @@ def __init__(
if self._api_token is not None:
self._data_source = "esios"
assert (data_source != "esios") or self._api_token is not None, data_source
self._user_agents = deque(sorted(_STANDARD_USER_AGENTS, key=lambda x: random()))
self._user_agents = deque(sorted(_STANDARD_USER_AGENTS, key=lambda _: random()))

self._local_timezone = zoneinfo.ZoneInfo(str(local_timezone))
assert tariff in TARIFFS
Expand Down Expand Up @@ -241,17 +240,20 @@ async def async_update_all(
last_update=utc_now,
)

api_sensors = {
api_sensor_key
for sensor_key in self._sensor_keys
for api_sensor_key in SENSOR_KEY_TO_API_SERIES[sensor_key]
}
urls_now, urls_next = get_daily_urls_to_download(
self._data_source,
self._sensor_keys,
api_sensors,
local_ref_now,
next_day,
)
updated = False
tasks = []
for url_now, url_next, sensor_key in zip(
urls_now, urls_next, self._sensor_keys
):
for url_now, url_next, sensor_key in zip(urls_now, urls_next, api_sensors):
if sensor_key not in current_data.sensors:
current_data.sensors[sensor_key] = {}

Expand All @@ -266,7 +268,7 @@ async def async_update_all(
)

results = await asyncio.gather(*tasks)
for new_data, sensor_key in zip(results, self._sensor_keys):
for new_data, sensor_key in zip(results, api_sensors):
if new_data:
updated = True
current_data.sensors[sensor_key] = new_data
Expand All @@ -276,24 +278,11 @@ async def async_update_all(
current_data.data_source = self._data_source
current_data.last_update = utc_now

if (
KEY_PVPC in current_data.availability
and KEY_ADJUSTMENT in current_data.availability
):
self._calculate_indexed(current_data)

add_composed_price_sensors(current_data)
for sensor_key in current_data.sensors:
self.process_state_and_attributes(current_data, sensor_key, now)
return current_data

def _calculate_indexed(self, current_data: EsiosApiData):
pvpc = current_data.sensors[KEY_PVPC]
adjustment = current_data.sensors[KEY_ADJUSTMENT]
current_data.sensors[KEY_INDEXED] = {
date: pvpc[date] - adjustment[date] for date in pvpc
}
current_data.availability[KEY_INDEXED] = True

async def _update_prices_series(
self,
sensor_key: str,
Expand All @@ -309,7 +298,7 @@ async def _update_prices_series(
"[%s] Evening download avoided, now with %d prices from %s UTC",
sensor_key,
current_num_prices,
list(current_prices)[0].strftime("%Y-%m-%d %Hh"),
next(iter(current_prices)).strftime("%Y-%m-%d %Hh"),
)
return None
elif (
Expand All @@ -330,7 +319,7 @@ async def _update_prices_series(
return None

if current_num_prices and (
list(current_prices)[0].astimezone(REFERENCE_TZ).date()
next(iter(current_prices)).astimezone(REFERENCE_TZ).date()
== local_ref_now.date()
):
# avoid download of today prices
Expand All @@ -339,7 +328,7 @@ async def _update_prices_series(
sensor_key,
local_ref_now,
current_num_prices,
list(current_prices)[0].astimezone(REFERENCE_TZ).date(),
next(iter(current_prices)).astimezone(REFERENCE_TZ).date(),
local_ref_now.date(),
)
else:
Expand All @@ -361,7 +350,7 @@ async def _update_prices_series(
"[%s] Download done, now with %d prices from %s UTC",
sensor_key,
len(current_prices),
list(current_prices)[0].strftime("%Y-%m-%d %Hh"),
next(iter(current_prices)).strftime("%Y-%m-%d %Hh"),
)

return current_prices
Expand All @@ -386,7 +375,7 @@ def process_state_and_attributes(
"""
attributes: dict[str, Any] = {
"sensor_id": sensor_key,
"data_id": SENSOR_KEY_TO_DATAID[sensor_key],
"data_id": SENSOR_KEY_TO_DATAID.get(sensor_key, "composed"),
}
utc_time = ensure_utc_time(utc_now.replace(minute=0, second=0, microsecond=0))
actual_time = utc_time.astimezone(self._local_timezone)
Expand Down
Loading
Loading