Skip to content

Commit 9addcc9

Browse files
committed
add initial support for extreme price calculation service
1 parent 65f8c6f commit 9addcc9

File tree

6 files changed

+395
-54
lines changed

6 files changed

+395
-54
lines changed

.pre-commit-config.yaml

+40-43
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,58 @@
11
repos:
2-
- repo: https://github.com/asottile/pyupgrade
3-
rev: v2.32.1
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
rev: v0.0.285
44
hooks:
5-
- id: pyupgrade
6-
args: [--py37-plus]
7-
- repo: https://github.com/psf/black
8-
rev: 22.3.0
5+
- id: ruff
6+
args:
7+
- --fix
8+
- repo: https://github.com/psf/black-pre-commit-mirror
9+
rev: 23.9.0
910
hooks:
1011
- id: black
1112
args:
12-
- --safe
1313
- --quiet
14-
files: ^((custom_components|script|tests)/.+)?[^/]+\.py$
14+
files: ^((custom_components|pylint|script|tests)/.+)?[^/]+\.py$
1515
- repo: https://github.com/codespell-project/codespell
16-
rev: v2.1.0
16+
rev: v2.2.2
1717
hooks:
1818
- id: codespell
1919
args:
20-
- --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing, Adresse, termine, adresse
21-
- --skip="./.*,*.csv,*.json"
20+
- --ignore-words-list=additionals,alle,alot,bund,currenty,datas,farenheit,falsy,fo,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,withing,zar
21+
- --skip="./.*,*.csv,*.json,*.ambr"
2222
- --quiet-level=2
2323
exclude_types: [csv, json]
24-
- repo: https://github.com/pycqa/flake8
25-
rev: 3.9.2
26-
hooks:
27-
- id: flake8
28-
args:
29-
- --ignore=D100,D101,D102,D103,D104,D105,D107,E501,W503
30-
additional_dependencies:
31-
- flake8-docstrings==1.5.0
32-
- pydocstyle==5.0.2
33-
files: ^(custom_components|script|tests)/.+\.py$
34-
- repo: https://github.com/PyCQA/bandit
35-
rev: 1.7.4
36-
hooks:
37-
- id: bandit
38-
args:
39-
- --quiet
40-
- --format=custom
41-
- --configfile=tests/bandit.yaml
42-
files: ^(custom_components|script|tests)/.+\.py$
43-
- repo: https://github.com/pre-commit/mirrors-isort
44-
rev: v5.10.1
45-
hooks:
46-
- id: isort
24+
exclude: ^tests/fixtures/|homeassistant/generated/
4725
- repo: https://github.com/pre-commit/pre-commit-hooks
48-
rev: v4.2.0
26+
rev: v4.4.0
4927
hooks:
5028
- id: check-executables-have-shebangs
5129
stages: [manual]
5230
- id: check-json
53-
- repo: https://github.com/pre-commit/mirrors-mypy
54-
rev: v0.960
55-
hooks:
56-
- id: mypy
31+
exclude: (.vscode|.devcontainer)
32+
- id: no-commit-to-branch
33+
args:
34+
- --branch=dev
35+
- --branch=master
36+
- --branch=rc
37+
- repo: https://github.com/adrienverge/yamllint.git
38+
rev: v1.32.0
39+
hooks:
40+
- id: yamllint
41+
- repo: https://github.com/pre-commit/mirrors-prettier
42+
rev: v2.7.1
43+
hooks:
44+
- id: prettier
45+
- repo: https://github.com/cdce8p/python-typing-update
46+
rev: v0.6.0
47+
hooks:
48+
# Run `python-typing-update` hook manually from time to time
49+
# to update python typing syntax.
50+
# Will require manual work, before submitting changes!
51+
# pre-commit run --hook-stage manual python-typing-update --all-files
52+
- id: python-typing-update
53+
stages: [manual]
5754
args:
58-
- --pretty
59-
- --show-error-codes
60-
- --show-error-context
61-
- --ignore-missing-imports
55+
- --py311-plus
56+
- --force
57+
- --keep-updates
58+
files: ^(custom_components|tests|script)/.+\.py$

custom_components/epex_spot/__init__.py

+133-10
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,71 @@
11
"""Component for EPEX Spot support."""
22
import logging
3-
from datetime import timedelta
3+
from datetime import time, timedelta
4+
from typing import Callable, Dict
45

6+
import homeassistant.helpers.config_validation as cv
7+
import voluptuous as vol
58
from homeassistant.config_entries import ConfigEntry
6-
from homeassistant.core import HomeAssistant, callback
9+
from homeassistant.const import ATTR_DEVICE_ID
10+
from homeassistant.core import (
11+
HomeAssistant,
12+
ServiceCall,
13+
ServiceResponse,
14+
SupportsResponse,
15+
callback,
16+
)
17+
from homeassistant.exceptions import HomeAssistantError
18+
from homeassistant.helpers import device_registry as dr
719
from homeassistant.helpers.dispatcher import dispatcher_send
820
from homeassistant.helpers.event import async_track_time_change
921
from homeassistant.util import dt
1022

11-
from .const import (CONF_MARKET_AREA, CONF_SOURCE, CONF_SOURCE_AWATTAR,
12-
CONF_SOURCE_EPEX_SPOT_WEB, CONF_SOURCE_SMARD_DE,
13-
CONF_SURCHARGE_ABS, CONF_SURCHARGE_PERC, CONF_TAX,
14-
DEFAULT_SURCHARGE_ABS, DEFAULT_SURCHARGE_PERC, DEFAULT_TAX,
15-
DOMAIN, UPDATE_SENSORS_SIGNAL)
23+
from .const import (
24+
CONF_DURATION,
25+
CONF_EARLIEST_START,
26+
CONF_LATEST_END,
27+
CONF_MARKET_AREA,
28+
CONF_SOURCE,
29+
CONF_SOURCE_AWATTAR,
30+
CONF_SOURCE_EPEX_SPOT_WEB,
31+
CONF_SOURCE_SMARD_DE,
32+
CONF_SURCHARGE_ABS,
33+
CONF_SURCHARGE_PERC,
34+
CONF_TAX,
35+
DEFAULT_SURCHARGE_ABS,
36+
DEFAULT_SURCHARGE_PERC,
37+
DEFAULT_TAX,
38+
DOMAIN,
39+
UPDATE_SENSORS_SIGNAL,
40+
)
1641
from .EPEXSpot import SMARD, Awattar, EPEXSpotWeb
42+
from .extreme_price_interval import find_extreme_price_interval, get_start_times
1743

1844
_LOGGER = logging.getLogger(__name__)
1945

20-
2146
PLATFORMS = ["sensor"]
2247

48+
GET_EXTREME_PRICE_INTERVAL_SCHEMA = vol.Schema(
49+
{
50+
**cv.ENTITY_SERVICE_FIELDS, # for device_id
51+
vol.Optional(CONF_EARLIEST_START): cv.time,
52+
vol.Optional(CONF_LATEST_END): cv.time,
53+
vol.Required(CONF_DURATION): cv.positive_time_period,
54+
}
55+
)
56+
57+
EMPTY_EXTREME_PRICE_INTERVAL_RESP = {
58+
"start": None,
59+
"end": None,
60+
"price_eur_per_mwh": None,
61+
"price_ct_per_kwh": None,
62+
"net_price_ct_per_kwh": None,
63+
}
64+
2365

2466
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
25-
"""Set up component from a config entry, config_entry contains data from config entry database."""
67+
"""Set up component from a config entry, config_entry contains data
68+
from config entry database."""
2669
# store shell object
2770
shell = hass.data.setdefault(DOMAIN, EpexSpotShell(hass))
2871

@@ -33,6 +76,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
3376

3477
entry.async_on_unload(entry.add_update_listener(on_update_options_listener))
3578

79+
async def get_lowest_price_interval(call: ServiceCall) -> ServiceResponse:
80+
"""Get the time interval during which the price is at its lowest point."""
81+
return _find_extreme_price_interval(call, lambda a, b: a < b)
82+
83+
async def get_highest_price_interval(call: ServiceCall) -> ServiceResponse:
84+
"""Get the time interval during which the price is at its highest point."""
85+
return _find_extreme_price_interval(call, lambda a, b: a > b)
86+
87+
def _find_extreme_price_interval(
88+
call: ServiceCall, cmp: Callable[[float, float], bool]
89+
) -> ServiceResponse:
90+
if ATTR_DEVICE_ID in call.data:
91+
device_id = call.data[ATTR_DEVICE_ID][0]
92+
device_registry = dr.async_get(hass)
93+
if not (device_entry := device_registry.async_get(device_id)):
94+
raise HomeAssistantError(f"No device found for device id: {device_id}")
95+
source = shell.get_source_by_config_entry_id(
96+
next(iter(device_entry.config_entries))
97+
)
98+
else:
99+
source = next(iter(shell._sources.values()))
100+
101+
if source is None:
102+
return EMPTY_EXTREME_PRICE_INTERVAL_RESP
103+
104+
earliest_start_time = call.data.get(CONF_EARLIEST_START)
105+
latest_end_time = call.data.get(CONF_LATEST_END)
106+
duration = call.data[CONF_DURATION]
107+
return source.find_extreme_price_interval(
108+
earliest_start_time, latest_end_time, duration, cmp
109+
)
110+
111+
hass.services.async_register(
112+
DOMAIN,
113+
"get_lowest_price_interval",
114+
get_lowest_price_interval,
115+
schema=GET_EXTREME_PRICE_INTERVAL_SCHEMA,
116+
supports_response=SupportsResponse.ONLY,
117+
)
118+
hass.services.async_register(
119+
DOMAIN,
120+
"get_highest_price_interval",
121+
get_highest_price_interval,
122+
schema=GET_EXTREME_PRICE_INTERVAL_SCHEMA,
123+
supports_response=SupportsResponse.ONLY,
124+
)
125+
36126
return True
37127

38128

@@ -70,6 +160,10 @@ def __init__(self, config_entry, source):
70160
def unique_id(self):
71161
return self._config_entry.unique_id
72162

163+
@property
164+
def config_entry_id(self):
165+
return self._config_entry.entry_id
166+
73167
@property
74168
def name(self):
75169
return self._source.name
@@ -88,6 +182,7 @@ def marketdata_now(self):
88182

89183
@property
90184
def sorted_marketdata_today(self):
185+
"""Sorted by price."""
91186
return self._sorted_marketdata_today
92187

93188
def fetch(self):
@@ -142,14 +237,36 @@ def to_net_price(self, price_eur_per_mwh):
142237

143238
return net_p
144239

240+
def find_extreme_price_interval(
241+
self, earliestStartTime: time, latestEndTime: time, duration: timedelta, cmp
242+
):
243+
priceMap = {item.start_time: item.price_eur_per_mwh for item in self.marketdata}
244+
245+
startTimes = get_start_times(
246+
earliestStartTime, latestEndTime, self.marketdata[-1].end_time, duration
247+
)
248+
249+
result = find_extreme_price_interval(priceMap, startTimes, duration, cmp)
250+
251+
if result is None:
252+
return EMPTY_EXTREME_PRICE_INTERVAL_RESP
253+
254+
return {
255+
"start": result["start"],
256+
"end": result["start"] + duration,
257+
"price_eur_per_mwh": result["price"],
258+
"price_ct_per_kwh": result["price"] / 10,
259+
"net_price_ct_per_kwh": self.to_net_price(result["price"]),
260+
}
261+
145262

146263
class EpexSpotShell:
147264
"""Shell object for EPEX Spot. Stored in hass.data."""
148265

149266
def __init__(self, hass: HomeAssistant):
150267
"""Initialize the instance."""
151268
self._hass = hass
152-
self._sources = {}
269+
self._sources: Dict[str, SourceDecorator] = {}
153270
self._timer_listener_hour_change = None
154271
self._timer_listener_fetch = None
155272

@@ -159,6 +276,12 @@ def is_idle(self) -> bool:
159276
def get_source(self, unique_id):
160277
return self._sources[unique_id]
161278

279+
def get_source_by_config_entry_id(self, entry_id):
280+
for s in self._sources.values():
281+
if s.config_entry_id == entry_id:
282+
return s
283+
return None
284+
162285
def add_entry(self, config_entry: ConfigEntry):
163286
"""Add entry."""
164287
is_idle = self.is_idle()

custom_components/epex_spot/const.py

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
CONF_SURCHARGE_ABS = "absolute_surcharge"
1717
CONF_TAX = "tax"
1818

19+
# service call
20+
CONF_EARLIEST_START = "earliest_start"
21+
CONF_LATEST_END = "latest_end"
22+
CONF_DURATION = "duration"
23+
1924
DEFAULT_SURCHARGE_PERC = 3.0
2025
DEFAULT_SURCHARGE_ABS = 11.93
2126
DEFAULT_TAX = 19.0

0 commit comments

Comments
 (0)