mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-04-19 08:55:15 +00:00
initial commit
This commit is contained in:
parent
d2f6e9866d
commit
4c0afb6a2a
@ -16,8 +16,9 @@ from pydantic import Field, ValidationError
|
|||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceDataRecord, ElecPriceProvider
|
from akkudoktoreos.prediction.elecpriceabc import ElecPriceDataRecord, ElecPriceProvider
|
||||||
from akkudoktoreos.utils.cacheutil import CacheFileStore, cache_in_file
|
from akkudoktoreos.utils.cacheutil import cache_in_file
|
||||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
|
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||||
|
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -65,12 +66,6 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
|||||||
_update_data(): Processes and updates forecast data from Akkudoktor in ElecPriceDataRecord format.
|
_update_data(): Processes and updates forecast data from Akkudoktor in ElecPriceDataRecord format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
elecprice_8days: NDArray[Shape["24, 8"], float] = Field(
|
|
||||||
default=np.full((24, 8), np.nan),
|
|
||||||
description="Hourly electricity prices for the last 7 days and today (€/KWh). "
|
|
||||||
"A NumPy array of 24 elements, each representing the hourly prices "
|
|
||||||
"of the last 7 days (index 0..6, Monday..Sunday) and today (index 7).",
|
|
||||||
)
|
|
||||||
elecprice_8days_weights_day_of_week: NDArray[Shape["7, 8"], float] = Field(
|
elecprice_8days_weights_day_of_week: NDArray[Shape["7, 8"], float] = Field(
|
||||||
default=np.full((7, 8), np.nan),
|
default=np.full((7, 8), np.nan),
|
||||||
description="Daily electricity price weights for the last 7 days and today. "
|
description="Daily electricity price weights for the last 7 days and today. "
|
||||||
@ -100,54 +95,6 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
|||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
return akkudoktor_data
|
return akkudoktor_data
|
||||||
|
|
||||||
def historic_hours_min(self) -> int:
|
|
||||||
"""Return the minimum historic prediction hours for Akkudoktor electricity price data."""
|
|
||||||
return 5 * 7 * 24 # 5 weeks a 7 days a 24 hours
|
|
||||||
|
|
||||||
def _calculate_weighted_mean(self, day_of_week: int, hour: int) -> float:
|
|
||||||
"""Calculate the weighted mean price for given day_of_week and hour.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
day_of_week (int). The day of week to calculate the mean for (0=Monday..6).
|
|
||||||
hour (int): The hour week to calculate the mean for (0..23).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
price_weihgted_mead (float): Weighted mean price for given day_of:week and hour.
|
|
||||||
"""
|
|
||||||
if np.isnan(self.elecprice_8days_weights_day_of_week[0][0]):
|
|
||||||
# Weights not initialized - do now
|
|
||||||
|
|
||||||
# Priority of day: 1=most .. 7=least
|
|
||||||
priority_of_day = np.array(
|
|
||||||
# Available Prediction days /
|
|
||||||
# M,Tu,We,Th,Fr,Sa,Su,Today/ Forecast day_of_week
|
|
||||||
[
|
|
||||||
[1, 2, 3, 4, 5, 6, 7, 1], # Monday
|
|
||||||
[3, 1, 2, 4, 5, 6, 7, 1], # Tuesday
|
|
||||||
[4, 2, 1, 3, 5, 6, 7, 1], # Wednesday
|
|
||||||
[5, 4, 2, 1, 3, 6, 7, 1], # Thursday
|
|
||||||
[5, 4, 3, 2, 1, 6, 7, 1], # Friday
|
|
||||||
[7, 6, 5, 4, 2, 1, 3, 1], # Saturday
|
|
||||||
[7, 6, 5, 4, 3, 2, 1, 1], # Sunday
|
|
||||||
]
|
|
||||||
)
|
|
||||||
# Take priorities above to decrease relevance in 2s exponential
|
|
||||||
self.elecprice_8days_weights_day_of_week = 2 / (2**priority_of_day)
|
|
||||||
|
|
||||||
# Compute the weighted mean for day_of_week and hour
|
|
||||||
prices_of_hour = self.elecprice_8days[hour]
|
|
||||||
if np.isnan(prices_of_hour).all():
|
|
||||||
# No prediction prices available for this hour - use mean value of all prices
|
|
||||||
price_weighted_mean = np.nanmean(self.elecprice_marketprice_wh_8day)
|
|
||||||
else:
|
|
||||||
weights = self.elecprice_8days_weights_day_of_week[day_of_week]
|
|
||||||
prices_of_hour_masked: NDArray[Shape["24"]] = np.ma.MaskedArray(
|
|
||||||
prices_of_hour, mask=np.isnan(prices_of_hour)
|
|
||||||
)
|
|
||||||
price_weighted_mean = np.ma.average(prices_of_hour_masked, weights=weights)
|
|
||||||
|
|
||||||
return float(price_weighted_mean)
|
|
||||||
|
|
||||||
@cache_in_file(with_ttl="1 hour")
|
@cache_in_file(with_ttl="1 hour")
|
||||||
def _request_forecast(self) -> AkkudoktorElecPrice:
|
def _request_forecast(self) -> AkkudoktorElecPrice:
|
||||||
"""Fetch electricity price forecast data from Akkudoktor API.
|
"""Fetch electricity price forecast data from Akkudoktor API.
|
||||||
@ -162,8 +109,9 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
|||||||
ValueError: If the API response does not include expected `electricity price` data.
|
ValueError: If the API response does not include expected `electricity price` data.
|
||||||
"""
|
"""
|
||||||
source = "https://api.akkudoktor.net"
|
source = "https://api.akkudoktor.net"
|
||||||
# Try to take data from 7 days back for prediction - usually only some hours back are available
|
assert self.start_datetime # mypy fix
|
||||||
date = to_datetime(self.start_datetime - to_duration("7 days"), as_string="YYYY-MM-DD")
|
# Try to take data from 5 weeks back for prediction
|
||||||
|
date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
|
||||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||||
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
|
url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
|
||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
@ -174,6 +122,23 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
|||||||
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
self.update_datetime = to_datetime(in_timezone=self.config.timezone)
|
||||||
return akkudoktor_data
|
return akkudoktor_data
|
||||||
|
|
||||||
|
def cap_outliers(data, sigma=2):
|
||||||
|
mean = data.mean()
|
||||||
|
std = data.std()
|
||||||
|
lower_bound = mean - sigma * std
|
||||||
|
upper_bound = mean + sigma * std
|
||||||
|
capped_data = data.clip(lower=lower_bound, upper=upper_bound)
|
||||||
|
return capped_data
|
||||||
|
|
||||||
|
def predict_ets(
|
||||||
|
history,
|
||||||
|
seasonal_periods,
|
||||||
|
):
|
||||||
|
model = ExponentialSmoothing(
|
||||||
|
history, seasonal="add", seasonal_periods=seasonal_periods
|
||||||
|
).fit()
|
||||||
|
return model.forecast(7 * 24)
|
||||||
|
|
||||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||||
"""Update forecast data in the ElecPriceDataRecord format.
|
"""Update forecast data in the ElecPriceDataRecord format.
|
||||||
|
|
||||||
@ -187,77 +152,34 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
|
|||||||
|
|
||||||
# Assumption that all lists are the same length and are ordered chronologically
|
# Assumption that all lists are the same length and are ordered chronologically
|
||||||
# in ascending order and have the same timestamps.
|
# in ascending order and have the same timestamps.
|
||||||
values_len = len(akkudoktor_data.values)
|
|
||||||
if values_len < 1:
|
|
||||||
# Expect one value set per prediction hour
|
|
||||||
raise ValueError(
|
|
||||||
f"The forecast must have at least one dataset, "
|
|
||||||
f"but only {values_len} data sets are given in forecast data."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get cached 8day values
|
|
||||||
elecprice_cache_file = CacheFileStore().get(key="ElecPriceAkkudoktor8dayCache")
|
|
||||||
if elecprice_cache_file is None:
|
|
||||||
# Cache does not exist - create it
|
|
||||||
elecprice_cache_file = CacheFileStore().create(
|
|
||||||
key="ElecPriceAkkudoktor8dayCache",
|
|
||||||
until_datetime=to_datetime("infinity"),
|
|
||||||
suffix=".npy",
|
|
||||||
)
|
|
||||||
np.save(elecprice_cache_file, self.elecprice_8days)
|
|
||||||
elecprice_cache_file.seek(0)
|
|
||||||
self.elecprice_8days = np.load(elecprice_cache_file)
|
|
||||||
|
|
||||||
# Get elecprice_charges_kwh_kwh
|
# Get elecprice_charges_kwh_kwh
|
||||||
charges_kwh = (
|
charges_wh = (
|
||||||
self.config.elecprice_charges_kwh if self.config.elecprice_charges_kwh else 0.0
|
self.config.elecprice_charges_kwh / 1000 if self.config.elecprice_charges_kwh else 0.0
|
||||||
)
|
)
|
||||||
|
assert self.start_datetime # mypy fix
|
||||||
|
|
||||||
for i in range(values_len):
|
for akkudoktor_value in akkudoktor_data.values:
|
||||||
original_datetime = akkudoktor_data.values[i].start
|
orig_datetime = to_datetime(akkudoktor_value.start, in_timezone=self.config.timezone)
|
||||||
dt = to_datetime(original_datetime, in_timezone=self.config.timezone)
|
|
||||||
|
|
||||||
akkudoktor_value = akkudoktor_data.values[i]
|
price_wh = akkudoktor_value.marketpriceEurocentPerKWh / (100 * 1000) + charges_wh
|
||||||
price_wh = (
|
|
||||||
akkudoktor_value.marketpriceEurocentPerKWh / (100 * 1000) + charges_kwh / 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
# We provide prediction starting at start of day, to be compatible to old system.
|
|
||||||
if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
|
|
||||||
# forecast data is too old - older than start_datetime with time set to 00:00:00
|
|
||||||
self.elecprice_8days[dt.hour, dt.day_of_week] = price_wh
|
|
||||||
continue
|
|
||||||
self.elecprice_8days[dt.hour, 7] = price_wh
|
|
||||||
|
|
||||||
self.update_value(dt, "elecprice_marketprice_wh", price_wh)
|
|
||||||
|
|
||||||
# Update 8day cache
|
|
||||||
elecprice_cache_file.seek(0)
|
|
||||||
np.save(elecprice_cache_file, self.elecprice_8days)
|
|
||||||
|
|
||||||
# Check for new/ valid forecast data
|
|
||||||
if len(self) == 0:
|
|
||||||
# Got no valid forecast data
|
|
||||||
return
|
|
||||||
|
|
||||||
# Assure price starts at start_time
|
|
||||||
while compare_datetimes(self[0].date_time, self.start_datetime).gt:
|
|
||||||
# Repeat the mean on the 8 day array to cover the missing hours
|
|
||||||
dt = self[0].date_time.subtract(hours=1) # type: ignore
|
|
||||||
value = self._calculate_weighted_mean(dt.day_of_week, dt.hour)
|
|
||||||
|
|
||||||
record = ElecPriceDataRecord(
|
record = ElecPriceDataRecord(
|
||||||
date_time=dt,
|
date_time=orig_datetime,
|
||||||
elecprice_marketprice_wh=value,
|
elecprice_marketprice_wh=price_wh,
|
||||||
)
|
)
|
||||||
self.insert(0, record)
|
self.insert(
|
||||||
# Assure price ends at end_time
|
0, record
|
||||||
while compare_datetimes(self[-1].date_time, self.end_datetime).lt:
|
) # idk what happens if the date is already there. try except update?
|
||||||
# Repeat the mean on the 8 day array to cover the missing hours
|
|
||||||
dt = self[-1].date_time.add(hours=1) # type: ignore
|
# now we check if we have data newer than the last from the api. if so thats old prediction. we delete them all.
|
||||||
value = self._calculate_weighted_mean(dt.day_of_week, dt.hour)
|
|
||||||
record = ElecPriceDataRecord(
|
# now we count how many data points we have.
|
||||||
date_time=dt,
|
# if its > 800 (5 weeks) we will use EST
|
||||||
elecprice_marketprice_wh=value,
|
# elif > idk maybe 168 (1 week) we use EST without season
|
||||||
)
|
# elif < 168 we use a simple median
|
||||||
self.append(record)
|
# #elif == 0 we need some static value from the config
|
||||||
|
|
||||||
|
# depending on the result we check prediction_hours and predict that many hours.
|
||||||
|
|
||||||
|
# we get the result and iterate over it to put it into ElecPriceDataRecord
|
||||||
|
Loading…
x
Reference in New Issue
Block a user