mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-09-20 02:31:14 +00:00
Structure code in logically separated submodules (#188)
This commit is contained in:
0
src/akkudoktoreos/prediction/__init__.py
Normal file
0
src/akkudoktoreos/prediction/__init__.py
Normal file
199
src/akkudoktoreos/prediction/ems.py
Normal file
199
src/akkudoktoreos/prediction/ems.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from akkudoktoreos.config import EOSConfig
|
||||
from akkudoktoreos.devices.battery import PVAkku
|
||||
from akkudoktoreos.devices.generic import Haushaltsgeraet
|
||||
from akkudoktoreos.devices.inverter import Wechselrichter
|
||||
|
||||
|
||||
class EnergieManagementSystemParameters(BaseModel):
|
||||
pv_prognose_wh: list[float] = Field(
|
||||
description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
|
||||
)
|
||||
strompreis_euro_pro_wh: list[float] = Field(
|
||||
description="An array of floats representing the electricity price in euros per watt-hour for different time intervals."
|
||||
)
|
||||
einspeiseverguetung_euro_pro_wh: list[float] | float = Field(
|
||||
description="A float or array of floats representing the feed-in compensation in euros per watt-hour."
|
||||
)
|
||||
preis_euro_pro_wh_akku: float
|
||||
gesamtlast: list[float] = Field(
|
||||
description="An array of floats representing the total load (consumption) in watts for different time intervals."
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_list_length(self) -> Self:
|
||||
pv_prognose_length = len(self.pv_prognose_wh)
|
||||
if (
|
||||
pv_prognose_length != len(self.strompreis_euro_pro_wh)
|
||||
or pv_prognose_length != len(self.gesamtlast)
|
||||
or (
|
||||
isinstance(self.einspeiseverguetung_euro_pro_wh, list)
|
||||
and pv_prognose_length != len(self.einspeiseverguetung_euro_pro_wh)
|
||||
)
|
||||
):
|
||||
raise ValueError("Input lists have different lengths")
|
||||
return self
|
||||
|
||||
|
||||
class EnergieManagementSystem:
|
||||
def __init__(
|
||||
self,
|
||||
config: EOSConfig,
|
||||
parameters: EnergieManagementSystemParameters,
|
||||
eauto: Optional[PVAkku] = None,
|
||||
haushaltsgeraet: Optional[Haushaltsgeraet] = None,
|
||||
wechselrichter: Optional[Wechselrichter] = None,
|
||||
):
|
||||
self.akku = wechselrichter.akku
|
||||
self.gesamtlast = np.array(parameters.gesamtlast, float)
|
||||
self.pv_prognose_wh = np.array(parameters.pv_prognose_wh, float)
|
||||
self.strompreis_euro_pro_wh = np.array(parameters.strompreis_euro_pro_wh, float)
|
||||
self.einspeiseverguetung_euro_pro_wh_arr = (
|
||||
parameters.einspeiseverguetung_euro_pro_wh
|
||||
if isinstance(parameters.einspeiseverguetung_euro_pro_wh, list)
|
||||
else np.full(len(self.gesamtlast), parameters.einspeiseverguetung_euro_pro_wh, float)
|
||||
)
|
||||
self.eauto = eauto
|
||||
self.haushaltsgeraet = haushaltsgeraet
|
||||
self.wechselrichter = wechselrichter
|
||||
self.ac_charge_hours = np.full(config.prediction_hours, 0)
|
||||
self.dc_charge_hours = np.full(config.prediction_hours, 1)
|
||||
self.ev_charge_hours = np.full(config.prediction_hours, 0)
|
||||
|
||||
def set_akku_discharge_hours(self, ds: List[int]) -> None:
|
||||
self.akku.set_discharge_per_hour(ds)
|
||||
|
||||
def set_akku_ac_charge_hours(self, ds: np.ndarray) -> None:
|
||||
self.ac_charge_hours = ds
|
||||
|
||||
def set_akku_dc_charge_hours(self, ds: np.ndarray) -> None:
|
||||
self.dc_charge_hours = ds
|
||||
|
||||
def set_ev_charge_hours(self, ds: List[int]) -> None:
|
||||
self.ev_charge_hours = ds
|
||||
|
||||
def set_haushaltsgeraet_start(self, ds: List[int], global_start_hour: int = 0) -> None:
|
||||
self.haushaltsgeraet.set_startzeitpunkt(ds, global_start_hour=global_start_hour)
|
||||
|
||||
def reset(self) -> None:
|
||||
self.eauto.reset()
|
||||
self.akku.reset()
|
||||
|
||||
def simuliere_ab_jetzt(self) -> dict:
|
||||
jetzt = datetime.now()
|
||||
start_stunde = jetzt.hour
|
||||
return self.simuliere(start_stunde)
|
||||
|
||||
def simuliere(self, start_stunde: int) -> dict:
|
||||
"""hour.
|
||||
|
||||
akku_soc_pro_stunde begin of the hour, initial hour state!
|
||||
last_wh_pro_stunde integral of last hour (end state)
|
||||
"""
|
||||
lastkurve_wh = self.gesamtlast
|
||||
assert (
|
||||
len(lastkurve_wh) == len(self.pv_prognose_wh) == len(self.strompreis_euro_pro_wh)
|
||||
), f"Array sizes do not match: Load Curve = {len(lastkurve_wh)}, PV Forecast = {len(self.pv_prognose_wh)}, Electricity Price = {len(self.strompreis_euro_pro_wh)}"
|
||||
|
||||
# Optimized total hours calculation
|
||||
ende = len(lastkurve_wh)
|
||||
total_hours = ende - start_stunde
|
||||
|
||||
# Pre-allocate arrays for the results, optimized for speed
|
||||
last_wh_pro_stunde = np.full((total_hours), np.nan)
|
||||
netzeinspeisung_wh_pro_stunde = np.full((total_hours), np.nan)
|
||||
netzbezug_wh_pro_stunde = np.full((total_hours), np.nan)
|
||||
kosten_euro_pro_stunde = np.full((total_hours), np.nan)
|
||||
einnahmen_euro_pro_stunde = np.full((total_hours), np.nan)
|
||||
akku_soc_pro_stunde = np.full((total_hours), np.nan)
|
||||
eauto_soc_pro_stunde = np.full((total_hours), np.nan)
|
||||
verluste_wh_pro_stunde = np.full((total_hours), np.nan)
|
||||
haushaltsgeraet_wh_pro_stunde = np.full((total_hours), np.nan)
|
||||
|
||||
# Set initial state
|
||||
akku_soc_pro_stunde[0] = self.akku.ladezustand_in_prozent()
|
||||
if self.eauto:
|
||||
eauto_soc_pro_stunde[0] = self.eauto.ladezustand_in_prozent()
|
||||
|
||||
for stunde in range(start_stunde, ende):
|
||||
stunde_since_now = stunde - start_stunde
|
||||
|
||||
# Accumulate loads and PV generation
|
||||
verbrauch = self.gesamtlast[stunde]
|
||||
verluste_wh_pro_stunde[stunde_since_now] = 0.0
|
||||
if self.haushaltsgeraet:
|
||||
ha_load = self.haushaltsgeraet.get_last_fuer_stunde(stunde)
|
||||
verbrauch += ha_load
|
||||
haushaltsgeraet_wh_pro_stunde[stunde_since_now] = ha_load
|
||||
|
||||
# E-Auto handling
|
||||
if self.eauto and self.ev_charge_hours[stunde] > 0:
|
||||
geladene_menge_eauto, verluste_eauto = self.eauto.energie_laden(
|
||||
None, stunde, relative_power=self.ev_charge_hours[stunde]
|
||||
)
|
||||
verbrauch += geladene_menge_eauto
|
||||
verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto
|
||||
|
||||
if self.eauto:
|
||||
eauto_soc_pro_stunde[stunde_since_now] = self.eauto.ladezustand_in_prozent()
|
||||
# Process inverter logic
|
||||
erzeugung = self.pv_prognose_wh[stunde]
|
||||
self.akku.set_charge_allowed_for_hour(self.dc_charge_hours[stunde], stunde)
|
||||
netzeinspeisung, netzbezug, verluste, eigenverbrauch = (
|
||||
self.wechselrichter.energie_verarbeiten(erzeugung, verbrauch, stunde)
|
||||
)
|
||||
|
||||
# AC PV Battery Charge
|
||||
if self.ac_charge_hours[stunde] > 0.0:
|
||||
self.akku.set_charge_allowed_for_hour(1, stunde)
|
||||
geladene_menge, verluste_wh = self.akku.energie_laden(
|
||||
None, stunde, relative_power=self.ac_charge_hours[stunde]
|
||||
)
|
||||
# print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.akku.ladezustand_in_prozent())
|
||||
verbrauch += geladene_menge
|
||||
netzbezug += geladene_menge
|
||||
verluste_wh_pro_stunde[stunde_since_now] += verluste_wh
|
||||
|
||||
netzeinspeisung_wh_pro_stunde[stunde_since_now] = netzeinspeisung
|
||||
netzbezug_wh_pro_stunde[stunde_since_now] = netzbezug
|
||||
verluste_wh_pro_stunde[stunde_since_now] += verluste
|
||||
last_wh_pro_stunde[stunde_since_now] = verbrauch
|
||||
|
||||
# Financial calculations
|
||||
kosten_euro_pro_stunde[stunde_since_now] = (
|
||||
netzbezug * self.strompreis_euro_pro_wh[stunde]
|
||||
)
|
||||
einnahmen_euro_pro_stunde[stunde_since_now] = (
|
||||
netzeinspeisung * self.einspeiseverguetung_euro_pro_wh_arr[stunde]
|
||||
)
|
||||
|
||||
# Akku SOC tracking
|
||||
akku_soc_pro_stunde[stunde_since_now] = self.akku.ladezustand_in_prozent()
|
||||
|
||||
# Total cost and return
|
||||
gesamtkosten_euro = np.nansum(kosten_euro_pro_stunde) - np.nansum(einnahmen_euro_pro_stunde)
|
||||
|
||||
# Prepare output dictionary
|
||||
out: Dict[str, Union[np.ndarray, float]] = {
|
||||
"Last_Wh_pro_Stunde": last_wh_pro_stunde,
|
||||
"Netzeinspeisung_Wh_pro_Stunde": netzeinspeisung_wh_pro_stunde,
|
||||
"Netzbezug_Wh_pro_Stunde": netzbezug_wh_pro_stunde,
|
||||
"Kosten_Euro_pro_Stunde": kosten_euro_pro_stunde,
|
||||
"akku_soc_pro_stunde": akku_soc_pro_stunde,
|
||||
"Einnahmen_Euro_pro_Stunde": einnahmen_euro_pro_stunde,
|
||||
"Gesamtbilanz_Euro": gesamtkosten_euro,
|
||||
"EAuto_SoC_pro_Stunde": eauto_soc_pro_stunde,
|
||||
"Gesamteinnahmen_Euro": np.nansum(einnahmen_euro_pro_stunde),
|
||||
"Gesamtkosten_Euro": np.nansum(kosten_euro_pro_stunde),
|
||||
"Verluste_Pro_Stunde": verluste_wh_pro_stunde,
|
||||
"Gesamt_Verluste": np.nansum(verluste_wh_pro_stunde),
|
||||
"Haushaltsgeraet_wh_pro_stunde": haushaltsgeraet_wh_pro_stunde,
|
||||
}
|
||||
|
||||
return out
|
37
src/akkudoktoreos/prediction/load_container.py
Normal file
37
src/akkudoktoreos/prediction/load_container.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import numpy as np
|
||||
|
||||
|
||||
class Gesamtlast:
|
||||
def __init__(self, prediction_hours=24):
|
||||
self.lasten = {} # Contains names and load arrays for different sources
|
||||
self.prediction_hours = prediction_hours
|
||||
|
||||
def hinzufuegen(self, name, last_array):
|
||||
"""Adds an array of loads for a specific source.
|
||||
|
||||
:param name: Name of the load source (e.g., "Household", "Heat Pump")
|
||||
:param last_array: Array of loads, where each entry corresponds to an hour
|
||||
"""
|
||||
if len(last_array) != self.prediction_hours:
|
||||
raise ValueError(f"Total load inconsistent lengths in arrays: {name} {len(last_array)}")
|
||||
self.lasten[name] = last_array
|
||||
|
||||
def gesamtlast_berechnen(self):
|
||||
"""Calculates the total load for each hour and returns an array of total loads.
|
||||
|
||||
:return: Array of total loads, where each entry corresponds to an hour
|
||||
"""
|
||||
if not self.lasten:
|
||||
return []
|
||||
|
||||
# Assumption: All load arrays have the same length
|
||||
stunden = len(next(iter(self.lasten.values())))
|
||||
gesamtlast_array = [0] * stunden
|
||||
|
||||
for last_array in self.lasten.values():
|
||||
gesamtlast_array = [
|
||||
gesamtlast + stundenlast
|
||||
for gesamtlast, stundenlast in zip(gesamtlast_array, last_array)
|
||||
]
|
||||
|
||||
return np.array(gesamtlast_array)
|
198
src/akkudoktoreos/prediction/load_corrector.py
Normal file
198
src/akkudoktoreos/prediction/load_corrector.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.metrics import mean_squared_error, r2_score
|
||||
|
||||
|
||||
class LoadPredictionAdjuster:
|
||||
def __init__(self, measured_data, predicted_data, load_forecast):
|
||||
self.measured_data = measured_data
|
||||
self.predicted_data = predicted_data
|
||||
self.load_forecast = load_forecast
|
||||
self.merged_data = self._merge_data()
|
||||
self.train_data = None
|
||||
self.test_data = None
|
||||
self.weekday_diff = None
|
||||
self.weekend_diff = None
|
||||
|
||||
def _remove_outliers(self, data, threshold=2):
|
||||
# Calculate the Z-Score of the 'Last' data
|
||||
data["Z-Score"] = np.abs((data["Last"] - data["Last"].mean()) / data["Last"].std())
|
||||
# Filter the data based on the threshold
|
||||
filtered_data = data[data["Z-Score"] < threshold]
|
||||
return filtered_data.drop(columns=["Z-Score"])
|
||||
|
||||
def _merge_data(self):
|
||||
# Convert the time column in both DataFrames to datetime
|
||||
self.predicted_data["time"] = pd.to_datetime(self.predicted_data["time"])
|
||||
self.measured_data["time"] = pd.to_datetime(self.measured_data["time"])
|
||||
|
||||
# Ensure both time columns have the same timezone
|
||||
if self.measured_data["time"].dt.tz is None:
|
||||
self.measured_data["time"] = self.measured_data["time"].dt.tz_localize("UTC")
|
||||
|
||||
self.predicted_data["time"] = (
|
||||
self.predicted_data["time"].dt.tz_localize("UTC").dt.tz_convert("Europe/Berlin")
|
||||
)
|
||||
self.measured_data["time"] = self.measured_data["time"].dt.tz_convert("Europe/Berlin")
|
||||
|
||||
# Optionally: Remove timezone information if only working locally
|
||||
self.predicted_data["time"] = self.predicted_data["time"].dt.tz_localize(None)
|
||||
self.measured_data["time"] = self.measured_data["time"].dt.tz_localize(None)
|
||||
|
||||
# Now you can perform the merge
|
||||
merged_data = pd.merge(self.measured_data, self.predicted_data, on="time", how="inner")
|
||||
print(merged_data)
|
||||
merged_data["Hour"] = merged_data["time"].dt.hour
|
||||
merged_data["DayOfWeek"] = merged_data["time"].dt.dayofweek
|
||||
return merged_data
|
||||
|
||||
def calculate_weighted_mean(self, train_period_weeks=9, test_period_weeks=1):
|
||||
self.merged_data = self._remove_outliers(self.merged_data)
|
||||
train_end_date = self.merged_data["time"].max() - pd.Timedelta(weeks=test_period_weeks)
|
||||
train_start_date = train_end_date - pd.Timedelta(weeks=train_period_weeks)
|
||||
|
||||
test_start_date = train_end_date + pd.Timedelta(hours=1)
|
||||
test_end_date = (
|
||||
test_start_date + pd.Timedelta(weeks=test_period_weeks) - pd.Timedelta(hours=1)
|
||||
)
|
||||
|
||||
self.train_data = self.merged_data[
|
||||
(self.merged_data["time"] >= train_start_date)
|
||||
& (self.merged_data["time"] <= train_end_date)
|
||||
]
|
||||
|
||||
self.test_data = self.merged_data[
|
||||
(self.merged_data["time"] >= test_start_date)
|
||||
& (self.merged_data["time"] <= test_end_date)
|
||||
]
|
||||
|
||||
self.train_data["Difference"] = self.train_data["Last"] - self.train_data["Last Pred"]
|
||||
|
||||
weekdays_train_data = self.train_data[self.train_data["DayOfWeek"] < 5]
|
||||
weekends_train_data = self.train_data[self.train_data["DayOfWeek"] >= 5]
|
||||
|
||||
self.weekday_diff = (
|
||||
weekdays_train_data.groupby("Hour").apply(self._weighted_mean_diff).dropna()
|
||||
)
|
||||
self.weekend_diff = (
|
||||
weekends_train_data.groupby("Hour").apply(self._weighted_mean_diff).dropna()
|
||||
)
|
||||
|
||||
def _weighted_mean_diff(self, data):
|
||||
train_end_date = self.train_data["time"].max()
|
||||
weights = 1 / (train_end_date - data["time"]).dt.days.replace(0, np.nan)
|
||||
weighted_mean = (data["Difference"] * weights).sum() / weights.sum()
|
||||
return weighted_mean
|
||||
|
||||
def adjust_predictions(self):
|
||||
self.train_data["Adjusted Pred"] = self.train_data.apply(self._adjust_row, axis=1)
|
||||
self.test_data["Adjusted Pred"] = self.test_data.apply(self._adjust_row, axis=1)
|
||||
|
||||
def _adjust_row(self, row):
|
||||
if row["DayOfWeek"] < 5:
|
||||
return row["Last Pred"] + self.weekday_diff.get(row["Hour"], 0)
|
||||
else:
|
||||
return row["Last Pred"] + self.weekend_diff.get(row["Hour"], 0)
|
||||
|
||||
def plot_results(self):
|
||||
self._plot_data(self.train_data, "Training")
|
||||
self._plot_data(self.test_data, "Testing")
|
||||
|
||||
def _plot_data(self, data, data_type):
|
||||
plt.figure(figsize=(14, 7))
|
||||
plt.plot(data["time"], data["Last"], label=f"Actual Last - {data_type}", color="blue")
|
||||
plt.plot(
|
||||
data["time"],
|
||||
data["Last Pred"],
|
||||
label=f"Predicted Last - {data_type}",
|
||||
color="red",
|
||||
linestyle="--",
|
||||
)
|
||||
plt.plot(
|
||||
data["time"],
|
||||
data["Adjusted Pred"],
|
||||
label=f"Adjusted Predicted Last - {data_type}",
|
||||
color="green",
|
||||
linestyle=":",
|
||||
)
|
||||
plt.xlabel("Time")
|
||||
plt.ylabel("Load")
|
||||
plt.title(f"Actual vs Predicted vs Adjusted Predicted Load ({data_type} Data)")
|
||||
plt.legend()
|
||||
plt.grid(True)
|
||||
plt.show()
|
||||
|
||||
def evaluate_model(self):
|
||||
mse = mean_squared_error(self.test_data["Last"], self.test_data["Adjusted Pred"])
|
||||
r2 = r2_score(self.test_data["Last"], self.test_data["Adjusted Pred"])
|
||||
print(f"Mean Squared Error: {mse}")
|
||||
print(f"R-squared: {r2}")
|
||||
|
||||
def predict_next_hours(self, hours_ahead):
|
||||
last_date = self.merged_data["time"].max()
|
||||
future_dates = [last_date + pd.Timedelta(hours=i) for i in range(1, hours_ahead + 1)]
|
||||
future_df = pd.DataFrame({"time": future_dates})
|
||||
future_df["Hour"] = future_df["time"].dt.hour
|
||||
future_df["DayOfWeek"] = future_df["time"].dt.dayofweek
|
||||
future_df["Last Pred"] = future_df["time"].apply(self._forecast_next_hours)
|
||||
future_df["Adjusted Pred"] = future_df.apply(self._adjust_row, axis=1)
|
||||
return future_df
|
||||
|
||||
def _forecast_next_hours(self, timestamp):
|
||||
date_str = timestamp.strftime("%Y-%m-%d")
|
||||
hour = timestamp.hour
|
||||
daily_forecast = self.load_forecast.get_daily_stats(date_str)
|
||||
return daily_forecast[0][hour] if hour < len(daily_forecast[0]) else np.nan
|
||||
|
||||
|
||||
# if __name__ == '__main__':
|
||||
# estimator = LastEstimator()
|
||||
# start_date = "2024-06-01"
|
||||
# end_date = "2024-08-01"
|
||||
# last_df = estimator.get_last(start_date, end_date)
|
||||
|
||||
# selected_columns = last_df[['timestamp', 'Last']]
|
||||
# selected_columns['time'] = pd.to_datetime(selected_columns['timestamp']).dt.floor('H')
|
||||
# selected_columns['Last'] = pd.to_numeric(selected_columns['Last'], errors='coerce')
|
||||
|
||||
# # Drop rows with NaN values
|
||||
# cleaned_data = selected_columns.dropna()
|
||||
|
||||
# print(cleaned_data)
|
||||
# # Create an instance of LoadForecast
|
||||
# lf = LoadForecast(filepath=r'.\load_profiles.npz', year_energy=6000*1000)
|
||||
|
||||
# # Initialize an empty DataFrame to hold the forecast data
|
||||
# forecast_list = []
|
||||
|
||||
# # Loop through each day in the date range
|
||||
# for single_date in pd.date_range(cleaned_data['time'].min().date(), cleaned_data['time'].max().date()):
|
||||
# date_str = single_date.strftime('%Y-%m-%d')
|
||||
# daily_forecast = lf.get_daily_stats(date_str)
|
||||
# mean_values = daily_forecast[0] # Extract the mean values
|
||||
# hours = [single_date + pd.Timedelta(hours=i) for i in range(24)]
|
||||
# daily_forecast_df = pd.DataFrame({'time': hours, 'Last Pred': mean_values})
|
||||
# forecast_list.append(daily_forecast_df)
|
||||
|
||||
# # Concatenate all daily forecasts into a single DataFrame
|
||||
# forecast_df = pd.concat(forecast_list, ignore_index=True)
|
||||
|
||||
# # Create an instance of the LoadPredictionAdjuster class
|
||||
# adjuster = LoadPredictionAdjuster(cleaned_data, forecast_df, lf)
|
||||
|
||||
# # Calculate the weighted mean differences
|
||||
# adjuster.calculate_weighted_mean()
|
||||
|
||||
# # Adjust the predictions
|
||||
# adjuster.adjust_predictions()
|
||||
|
||||
# # Plot the results
|
||||
# adjuster.plot_results()
|
||||
|
||||
# # Evaluate the model
|
||||
# adjuster.evaluate_model()
|
||||
|
||||
# # Predict the next x hours
|
||||
# future_predictions = adjuster.predict_next_hours(48)
|
||||
# print(future_predictions)
|
99
src/akkudoktoreos/prediction/load_forecast.py
Normal file
99
src/akkudoktoreos/prediction/load_forecast.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Load the .npz file when the application starts
|
||||
|
||||
|
||||
class LoadForecast:
|
||||
def __init__(self, filepath=None, year_energy=None):
|
||||
self.filepath = filepath
|
||||
self.data = None
|
||||
self.data_year_energy = None
|
||||
self.year_energy = year_energy
|
||||
self.load_data()
|
||||
|
||||
def get_daily_stats(self, date_str):
|
||||
"""Returns the 24-hour profile with mean and standard deviation for a given date.
|
||||
|
||||
:param date_str: Date as a string in the format "YYYY-MM-DD"
|
||||
:return: An array with shape (2, 24), contains means and standard deviations
|
||||
"""
|
||||
# Convert the date string into a datetime object
|
||||
date = self._convert_to_datetime(date_str)
|
||||
|
||||
# Calculate the day of the year (1 to 365)
|
||||
day_of_year = date.timetuple().tm_yday
|
||||
|
||||
# Extract the 24-hour profile for the given date
|
||||
daily_stats = self.data_year_energy[day_of_year - 1] # -1 because indexing starts at 0
|
||||
return daily_stats
|
||||
|
||||
def get_hourly_stats(self, date_str, hour):
|
||||
"""Returns the mean and standard deviation for a specific hour of a given date.
|
||||
|
||||
:param date_str: Date as a string in the format "YYYY-MM-DD"
|
||||
:param hour: Specific hour (0 to 23)
|
||||
:return: An array with shape (2,), contains mean and standard deviation for the specified hour
|
||||
"""
|
||||
# Convert the date string into a datetime object
|
||||
date = self._convert_to_datetime(date_str)
|
||||
|
||||
# Calculate the day of the year (1 to 365)
|
||||
day_of_year = date.timetuple().tm_yday
|
||||
|
||||
# Extract mean and standard deviation for the given hour
|
||||
hourly_stats = self.data_year_energy[day_of_year - 1, :, hour] # Access the specific hour
|
||||
|
||||
return hourly_stats
|
||||
|
||||
def get_stats_for_date_range(self, start_date_str, end_date_str):
|
||||
"""Returns the means and standard deviations for a date range.
|
||||
|
||||
:param start_date_str: Start date as a string in the format "YYYY-MM-DD"
|
||||
:param end_date_str: End date as a string in the format "YYYY-MM-DD"
|
||||
:return: An array with aggregated data for the date range
|
||||
"""
|
||||
start_date = self._convert_to_datetime(start_date_str)
|
||||
end_date = self._convert_to_datetime(end_date_str)
|
||||
|
||||
start_day_of_year = start_date.timetuple().tm_yday
|
||||
end_day_of_year = end_date.timetuple().tm_yday
|
||||
|
||||
# Note that in leap years, the day of the year may need adjustment
|
||||
stats_for_range = self.data_year_energy[
|
||||
start_day_of_year:end_day_of_year
|
||||
] # -1 because indexing starts at 0
|
||||
stats_for_range = stats_for_range.swapaxes(1, 0)
|
||||
|
||||
stats_for_range = stats_for_range.reshape(stats_for_range.shape[0], -1)
|
||||
return stats_for_range
|
||||
|
||||
def load_data(self):
|
||||
"""Loads data from the specified file."""
|
||||
try:
|
||||
data = np.load(self.filepath)
|
||||
self.data = np.array(list(zip(data["yearly_profiles"], data["yearly_profiles_std"])))
|
||||
self.data_year_energy = self.data * self.year_energy
|
||||
# pprint(self.data_year_energy)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File {self.filepath} not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading data: {e}")
|
||||
|
||||
def get_price_data(self):
|
||||
"""Returns price data (currently not implemented)."""
|
||||
return self.price_data
|
||||
|
||||
def _convert_to_datetime(self, date_str):
|
||||
"""Converts a date string to a datetime object."""
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
|
||||
|
||||
# Example usage of the class
|
||||
if __name__ == "__main__":
|
||||
filepath = r"..\data\load_profiles.npz" # Adjust the path to the .npz file
|
||||
lf = LoadForecast(filepath=filepath, year_energy=2000)
|
||||
specific_date_prices = lf.get_daily_stats("2024-02-16") # Adjust date as needed
|
||||
specific_hour_stats = lf.get_hourly_stats("2024-02-16", 12) # Adjust date and hour as needed
|
||||
print(specific_hour_stats)
|
134
src/akkudoktoreos/prediction/price_forecast.py
Normal file
134
src/akkudoktoreos/prediction/price_forecast.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import hashlib
|
||||
import json
|
||||
import zoneinfo
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from akkudoktoreos.config import AppConfig, SetupIncomplete
|
||||
|
||||
|
||||
def repeat_to_shape(array, target_shape):
|
||||
# Check if the array fits the target shape
|
||||
if len(target_shape) != array.ndim:
|
||||
raise ValueError("Array and target shape must have the same number of dimensions")
|
||||
|
||||
# Number of repetitions per dimension
|
||||
repeats = tuple(target_shape[i] // array.shape[i] for i in range(array.ndim))
|
||||
|
||||
# Use np.tile to expand the array
|
||||
expanded_array = np.tile(array, repeats)
|
||||
return expanded_array
|
||||
|
||||
|
||||
class HourlyElectricityPriceForecast:
|
||||
def __init__(
|
||||
self, source: str | Path, config: AppConfig, charges=0.000228, use_cache=True
|
||||
): # 228
|
||||
self.cache_dir = config.working_dir / config.directories.cache
|
||||
self.use_cache = use_cache
|
||||
if not self.cache_dir.is_dir():
|
||||
raise SetupIncomplete(f"Output path does not exist: {self.cache_dir}.")
|
||||
|
||||
self.cache_time_file = self.cache_dir / "cache_timestamp.txt"
|
||||
self.prices = self.load_data(source)
|
||||
self.charges = charges
|
||||
self.prediction_hours = config.eos.prediction_hours
|
||||
|
||||
def load_data(self, source: str | Path):
|
||||
cache_file = self.get_cache_file(source)
|
||||
if isinstance(source, str):
|
||||
if cache_file.is_file() and not self.is_cache_expired() and self.use_cache:
|
||||
print("Loading data from cache...")
|
||||
with cache_file.open("r") as file:
|
||||
json_data = json.load(file)
|
||||
else:
|
||||
print("Loading data from the URL...")
|
||||
response = requests.get(source)
|
||||
if response.status_code == 200:
|
||||
json_data = response.json()
|
||||
with cache_file.open("w") as file:
|
||||
json.dump(json_data, file)
|
||||
self.update_cache_timestamp()
|
||||
else:
|
||||
raise Exception(f"Error fetching data: {response.status_code}")
|
||||
elif source.is_file():
|
||||
with source.open("r") as file:
|
||||
json_data = json.load(file)
|
||||
else:
|
||||
raise ValueError(f"Input is not a valid path: {source}")
|
||||
return json_data["values"]
|
||||
|
||||
def get_cache_file(self, url):
|
||||
hash_object = hashlib.sha256(url.encode())
|
||||
hex_dig = hash_object.hexdigest()
|
||||
return self.cache_dir / f"cache_{hex_dig}.json"
|
||||
|
||||
def is_cache_expired(self):
|
||||
if not self.cache_time_file.is_file():
|
||||
return True
|
||||
with self.cache_time_file.open("r") as file:
|
||||
timestamp_str = file.read()
|
||||
last_cache_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
return datetime.now() - last_cache_time > timedelta(hours=1)
|
||||
|
||||
def update_cache_timestamp(self):
|
||||
with self.cache_time_file.open("w") as file:
|
||||
file.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
def get_price_for_date(self, date_str):
|
||||
"""Returns all prices for the specified date, including the price from 00:00 of the previous day."""
|
||||
# Convert date string to datetime object
|
||||
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
|
||||
# Calculate the previous day
|
||||
previous_day = date_obj - timedelta(days=1)
|
||||
previous_day_str = previous_day.strftime("%Y-%m-%d")
|
||||
|
||||
# Extract the price from 00:00 of the previous day
|
||||
last_price_of_previous_day = [
|
||||
entry["marketpriceEurocentPerKWh"] + self.charges
|
||||
for entry in self.prices
|
||||
if previous_day_str in entry["end"]
|
||||
][-1]
|
||||
|
||||
# Extract all prices for the specified date
|
||||
date_prices = [
|
||||
entry["marketpriceEurocentPerKWh"] + self.charges
|
||||
for entry in self.prices
|
||||
if date_str in entry["end"]
|
||||
]
|
||||
print(f"getPrice: {len(date_prices)}")
|
||||
|
||||
# Add the last price of the previous day at the start of the list
|
||||
if len(date_prices) == 23:
|
||||
date_prices.insert(0, last_price_of_previous_day)
|
||||
|
||||
return np.array(date_prices) / (1000.0 * 100.0) + self.charges
|
||||
|
||||
def get_price_for_daterange(self, start_date_str, end_date_str):
|
||||
"""Returns all prices between the start and end dates."""
|
||||
print(start_date_str)
|
||||
print(end_date_str)
|
||||
start_date_utc = datetime.strptime(start_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
end_date_utc = datetime.strptime(end_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
start_date = start_date_utc.astimezone(zoneinfo.ZoneInfo("Europe/Berlin"))
|
||||
end_date = end_date_utc.astimezone(zoneinfo.ZoneInfo("Europe/Berlin"))
|
||||
|
||||
price_list = []
|
||||
|
||||
while start_date < end_date:
|
||||
date_str = start_date.strftime("%Y-%m-%d")
|
||||
daily_prices = self.get_price_for_date(date_str)
|
||||
|
||||
if daily_prices.size == 24:
|
||||
price_list.extend(daily_prices)
|
||||
start_date += timedelta(days=1)
|
||||
|
||||
# If prediction hours are greater than 0, reshape the price list
|
||||
if self.prediction_hours > 0:
|
||||
price_list = repeat_to_shape(np.array(price_list), (self.prediction_hours,))
|
||||
|
||||
return price_list
|
682
src/akkudoktoreos/prediction/pv_forecast.py
Normal file
682
src/akkudoktoreos/prediction/pv_forecast.py
Normal file
@@ -0,0 +1,682 @@
|
||||
"""PV Power Forecasting Module.
|
||||
|
||||
This module contains classes and methods to retrieve, process, and display photovoltaic (PV)
|
||||
power forecast data, including temperature, windspeed, DC power, and AC power forecasts.
|
||||
The module supports caching of forecast data to reduce redundant network requests and includes
|
||||
functions to update AC power measurements and retrieve forecasts within a specified date range.
|
||||
|
||||
Classes
|
||||
ForecastData: Represents a single forecast entry, including DC power, AC power,
|
||||
temperature, and windspeed.
|
||||
PVForecast: Retrieves, processes, and stores PV power forecast data, either from
|
||||
a file or URL, with optional caching. It also provides methods to query
|
||||
and update the forecast data, convert it to a DataFrame, and output key
|
||||
metrics like AC power.
|
||||
|
||||
Example:
|
||||
# Initialize PVForecast class with an URL
|
||||
forecast = PVForecast(
|
||||
prediction_hours=24,
|
||||
url="https://api.akkudoktor.net/forecast?lat=52.52&lon=13.405..."
|
||||
)
|
||||
|
||||
# Update the AC power measurement for a specific date and time
|
||||
forecast.update_ac_power_measurement(date_time=datetime.now(), ac_power_measurement=1000)
|
||||
|
||||
# Print the forecast data with DC and AC power details
|
||||
forecast.print_ac_power_and_measurement()
|
||||
|
||||
# Get the forecast data as a Pandas DataFrame
|
||||
df = forecast.get_forecast_dataframe()
|
||||
print(df)
|
||||
|
||||
Attributes:
|
||||
prediction_hours (int): Number of forecast hours. Defaults to 48.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from akkudoktoreos.utils.cachefilestore import cache_in_file
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
from akkudoktoreos.utils.logutil import get_logger
|
||||
|
||||
logger = get_logger(__name__, logging_level="DEBUG")
|
||||
|
||||
|
||||
class AkkudoktorForecastHorizon(BaseModel):
|
||||
altitude: int
|
||||
azimuthFrom: int
|
||||
azimuthTo: int
|
||||
|
||||
|
||||
class AkkudoktorForecastMeta(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
power: List[int]
|
||||
azimuth: List[int]
|
||||
tilt: List[int]
|
||||
timezone: str
|
||||
albedo: float
|
||||
past_days: int
|
||||
inverterEfficiency: float
|
||||
powerInverter: List[int]
|
||||
cellCoEff: float
|
||||
range: bool
|
||||
horizont: List[List[AkkudoktorForecastHorizon]]
|
||||
horizontString: List[str]
|
||||
|
||||
|
||||
class AkkudoktorForecastValue(BaseModel):
|
||||
datetime: str
|
||||
dcPower: float
|
||||
power: float
|
||||
sunTilt: float
|
||||
sunAzimuth: float
|
||||
temperature: float
|
||||
relativehumidity_2m: float
|
||||
windspeed_10m: float
|
||||
|
||||
|
||||
class AkkudoktorForecast(BaseModel):
|
||||
meta: AkkudoktorForecastMeta
|
||||
values: List[List[AkkudoktorForecastValue]]
|
||||
|
||||
|
||||
def validate_pv_forecast_data(data) -> str:
|
||||
"""Validate PV forecast data."""
|
||||
data_type = None
|
||||
error_msg = ""
|
||||
|
||||
try:
|
||||
AkkudoktorForecast.model_validate(data)
|
||||
data_type = "Akkudoktor"
|
||||
except ValidationError as e:
|
||||
for error in e.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
message = error["msg"]
|
||||
error_type = error["type"]
|
||||
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
||||
logger.debug(f"Validation did not succeed: {error_msg}")
|
||||
|
||||
return data_type
|
||||
|
||||
|
||||
class ForecastResponse(BaseModel):
|
||||
temperature: list[float]
|
||||
pvpower: list[float]
|
||||
|
||||
|
||||
class ForecastData:
|
||||
"""Stores forecast data for PV power and weather parameters.
|
||||
|
||||
Attributes:
|
||||
date_time (datetime): The date and time of the forecast.
|
||||
dc_power (float): The direct current (DC) power in watts.
|
||||
ac_power (float): The alternating current (AC) power in watts.
|
||||
windspeed_10m (float, optional): Wind speed at 10 meters altitude.
|
||||
temperature (float, optional): Temperature in degrees Celsius.
|
||||
ac_power_measurement (float, optional): Measured AC power.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
date_time: datetime,
|
||||
dc_power: float,
|
||||
ac_power: float,
|
||||
windspeed_10m: Optional[float] = None,
|
||||
temperature: Optional[float] = None,
|
||||
ac_power_measurement: Optional[float] = None,
|
||||
):
|
||||
"""Initializes the ForecastData instance.
|
||||
|
||||
Args:
|
||||
date_time (datetime): The date and time of the forecast.
|
||||
dc_power (float): The DC power in watts.
|
||||
ac_power (float): The AC power in watts.
|
||||
windspeed_10m (float, optional): Wind speed at 10 meters altitude. Defaults to None.
|
||||
temperature (float, optional): Temperature in degrees Celsius. Defaults to None.
|
||||
ac_power_measurement (float, optional): Measured AC power. Defaults to None.
|
||||
"""
|
||||
self.date_time = date_time
|
||||
self.dc_power = dc_power
|
||||
self.ac_power = ac_power
|
||||
self.windspeed_10m = windspeed_10m
|
||||
self.temperature = temperature
|
||||
self.ac_power_measurement = ac_power_measurement
|
||||
|
||||
def get_date_time(self) -> datetime:
|
||||
"""Returns the forecast date and time.
|
||||
|
||||
Returns:
|
||||
datetime: The date and time of the forecast.
|
||||
"""
|
||||
return self.date_time
|
||||
|
||||
def get_dc_power(self) -> float:
|
||||
"""Returns the DC power.
|
||||
|
||||
Returns:
|
||||
float: DC power in watts.
|
||||
"""
|
||||
return self.dc_power
|
||||
|
||||
def ac_power_measurement(self) -> float:
|
||||
"""Returns the measured AC power.
|
||||
|
||||
It returns the measured AC power if available; otherwise None.
|
||||
|
||||
Returns:
|
||||
float: Measured AC power in watts or None
|
||||
"""
|
||||
return self.ac_power_measurement
|
||||
|
||||
def get_ac_power(self) -> float:
|
||||
"""Returns the AC power.
|
||||
|
||||
If a measured value is available, it returns the measured AC power;
|
||||
otherwise, it returns the forecasted AC power.
|
||||
|
||||
Returns:
|
||||
float: AC power in watts.
|
||||
"""
|
||||
if self.ac_power_measurement is not None:
|
||||
return self.ac_power_measurement
|
||||
else:
|
||||
return self.ac_power
|
||||
|
||||
def get_windspeed_10m(self) -> float:
|
||||
"""Returns the wind speed at 10 meters altitude.
|
||||
|
||||
Returns:
|
||||
float: Wind speed in meters per second.
|
||||
"""
|
||||
return self.windspeed_10m
|
||||
|
||||
def get_temperature(self) -> float:
|
||||
"""Returns the temperature.
|
||||
|
||||
Returns:
|
||||
float: Temperature in degrees Celsius.
|
||||
"""
|
||||
return self.temperature
|
||||
|
||||
|
||||
class PVForecast:
|
||||
"""Manages PV (photovoltaic) power forecasts and weather data.
|
||||
|
||||
Forecast data can be loaded from different sources (in-memory data, file, or URL).
|
||||
|
||||
Attributes:
|
||||
meta (dict): Metadata related to the forecast (e.g., source, location).
|
||||
forecast_data (list): A list of forecast data points of `ForecastData` objects.
|
||||
prediction_hours (int): The number of hours into the future the forecast covers.
|
||||
current_measurement (Optional[float]): The current AC power measurement in watts (or None if unavailable).
|
||||
data (Optional[dict]): JSON data containing the forecast information (if provided).
|
||||
filepath (Optional[str]): Filepath to the forecast data file (if provided).
|
||||
url (Optional[str]): URL to retrieve forecast data from an API (if provided).
|
||||
_forecast_start (Optional[date]): Start datetime for the forecast period.
|
||||
tz_name (Optional[str]): The time zone name of the forecast data, if applicable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Optional[dict] = None,
|
||||
filepath: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
forecast_start: Union[datetime, date, str, int, float] = None,
|
||||
prediction_hours: Optional[int] = None,
|
||||
):
|
||||
"""Initializes a `PVForecast` instance.
|
||||
|
||||
Forecast data can be loaded from in-memory `data`, a file specified by `filepath`, or
|
||||
fetched from a remote `url`. If none are provided, an empty forecast will be initialized.
|
||||
The `forecast_start` and `prediction_hours` parameters can be specified to control the
|
||||
forecasting time period.
|
||||
|
||||
Use `process_data()` to fill an empty forecast later on.
|
||||
|
||||
Args:
|
||||
data (Optional[dict]): In-memory JSON data containing forecast information. Defaults to None.
|
||||
filepath (Optional[str]): Path to a local file containing forecast data in JSON format. Defaults to None.
|
||||
url (Optional[str]): URL to an API providing forecast data. Defaults to None.
|
||||
forecast_start (Union[datetime, date, str, int, float]): The start datetime for the forecast period.
|
||||
Can be a `datetime`, `date`, `str` (formatted date), `int` (timestamp), `float`, or None. Defaults to None.
|
||||
prediction_hours (Optional[int]): The number of hours to forecast into the future. Defaults to 48 hours.
|
||||
|
||||
Example:
|
||||
forecast = PVForecast(data=my_forecast_data, forecast_start="2024-10-13", prediction_hours=72)
|
||||
"""
|
||||
self.meta = {}
|
||||
self.forecast_data = []
|
||||
self.current_measurement = None
|
||||
self.data = data
|
||||
self.filepath = filepath
|
||||
self.url = url
|
||||
if forecast_start:
|
||||
self._forecast_start = to_datetime(forecast_start, to_naiv=True, to_maxtime=False)
|
||||
else:
|
||||
self._forecast_start = None
|
||||
self.prediction_hours = prediction_hours
|
||||
self._tz_name = None
|
||||
|
||||
if self.data or self.filepath or self.url:
|
||||
self.process_data(
|
||||
data=self.data,
|
||||
filepath=self.filepath,
|
||||
url=self.url,
|
||||
forecast_start=self._forecast_start,
|
||||
prediction_hours=self.prediction_hours,
|
||||
)
|
||||
|
||||
def update_ac_power_measurement(
|
||||
self,
|
||||
date_time: Union[datetime, date, str, int, float, None] = None,
|
||||
ac_power_measurement=None,
|
||||
) -> bool:
|
||||
"""Updates the AC power measurement for a specific time.
|
||||
|
||||
Args:
|
||||
date_time (datetime): The date and time of the measurement.
|
||||
ac_power_measurement (float): Measured AC power.
|
||||
|
||||
Returns:
|
||||
bool: True if a matching timestamp was found, False otherwise.
|
||||
"""
|
||||
found = False
|
||||
input_date_hour = to_datetime(
|
||||
date_time, to_timezone=self._tz_name, to_naiv=True, to_maxtime=False
|
||||
).replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
for forecast in self.forecast_data:
|
||||
forecast_date_hour = to_datetime(forecast.date_time, to_naiv=True).replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
if forecast_date_hour == input_date_hour:
|
||||
forecast.ac_power_measurement = ac_power_measurement
|
||||
found = True
|
||||
logger.debug(
|
||||
f"AC Power measurement updated at date {input_date_hour}: {ac_power_measurement}"
|
||||
)
|
||||
break
|
||||
return found
|
||||
|
||||
def process_data(
|
||||
self,
|
||||
data: Optional[dict] = None,
|
||||
filepath: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
forecast_start: Union[datetime, date, str, int, float] = None,
|
||||
prediction_hours: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Processes the forecast data from the provided source (in-memory `data`, `filepath`, or `url`).
|
||||
|
||||
If `forecast_start` and `prediction_hours` are provided, they define the forecast period.
|
||||
|
||||
Args:
|
||||
data (Optional[dict]): JSON data containing forecast values. Defaults to None.
|
||||
filepath (Optional[str]): Path to a file with forecast data. Defaults to None.
|
||||
url (Optional[str]): API URL to retrieve forecast data from. Defaults to None.
|
||||
forecast_start (Union[datetime, date, str, int, float, None]): Start datetime of the forecast
|
||||
period. Defaults to None. If given before it is cached.
|
||||
prediction_hours (Optional[int]): The number of hours to forecast into the future.
|
||||
Defaults to None. If given before it is cached.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the specified `filepath` does not exist.
|
||||
ValueError: If no valid data source or data is provided.
|
||||
|
||||
Example:
|
||||
forecast = PVForecast(
|
||||
url="https://api.akkudoktor.net/forecast?lat=52.52&lon=13.405&"
|
||||
"power=5000&azimuth=-10&tilt=7&powerInvertor=10000&horizont=20,27,22,20&"
|
||||
"power=4800&azimuth=-90&tilt=7&powerInvertor=10000&horizont=30,30,30,50&"
|
||||
"power=1400&azimuth=-40&tilt=60&powerInvertor=2000&horizont=60,30,0,30&"
|
||||
"power=1600&azimuth=5&tilt=45&powerInvertor=1400&horizont=45,25,30,60&"
|
||||
"past_days=5&cellCoEff=-0.36&inverterEfficiency=0.8&albedo=0.25&"
|
||||
"timezone=Europe%2FBerlin&hourly=relativehumidity_2m%2Cwindspeed_10m",
|
||||
prediction_hours = 24,
|
||||
)
|
||||
"""
|
||||
# Get input forecast data
|
||||
if data:
|
||||
pass
|
||||
elif filepath:
|
||||
data = self.load_data_from_file(filepath)
|
||||
elif url:
|
||||
data = self.load_data_from_url_with_caching(url)
|
||||
elif self.data or self.filepath or self.url:
|
||||
# Re-process according to previous arguments
|
||||
if self.data:
|
||||
data = self.data
|
||||
elif self.filepath:
|
||||
data = self.load_data_from_file(self.filepath)
|
||||
elif self.url:
|
||||
data = self.load_data_from_url_with_caching(self.url)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Re-processing for None input is not implemented!"
|
||||
) # Invalid path
|
||||
else:
|
||||
raise ValueError("No prediction input data available.")
|
||||
# Validate input data to be of a known format
|
||||
data_format = validate_pv_forecast_data(data)
|
||||
if data_format != "Akkudoktor":
|
||||
raise ValueError(f"Prediction input data are of unknown format: '{data_format}'.")
|
||||
|
||||
# Assure we have a forecast start datetime
|
||||
if forecast_start is None:
|
||||
forecast_start = self._forecast_start
|
||||
if forecast_start is None:
|
||||
forecast_start = datetime(1970, 1, 1)
|
||||
|
||||
# Assure we have prediction hours set
|
||||
if prediction_hours is None:
|
||||
prediction_hours = self.prediction_hours
|
||||
if prediction_hours is None:
|
||||
prediction_hours = 48
|
||||
self.prediction_hours = prediction_hours
|
||||
|
||||
if data_format == "Akkudoktor":
|
||||
# --------------------------------------------
|
||||
# From here Akkudoktor PV forecast data format
|
||||
# ---------------------------------------------
|
||||
self.meta = data.get("meta")
|
||||
all_values = data.get("values")
|
||||
|
||||
# timezone of the PV system
|
||||
self._tz_name = self.meta.get("timezone", None)
|
||||
if not self._tz_name:
|
||||
raise NotImplementedError(
|
||||
"Processing without PV system timezone info ist not implemented!"
|
||||
)
|
||||
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
values_len = len(all_values[0])
|
||||
if values_len < self.prediction_hours:
|
||||
# Expect one value set per prediction hour
|
||||
raise ValueError(
|
||||
f"The forecast must cover at least {self.prediction_hours} hours, "
|
||||
f"but only {values_len} data sets are given in forecast data."
|
||||
)
|
||||
|
||||
# Convert forecast_start to timezone of PV system and make it a naiv datetime
|
||||
self._forecast_start = to_datetime(
|
||||
forecast_start, to_timezone=self._tz_name, to_naiv=True
|
||||
)
|
||||
logger.debug(f"Forecast start set to {self._forecast_start}")
|
||||
|
||||
for i in range(values_len):
|
||||
# Zeige die ursprünglichen und berechneten Zeitstempel an
|
||||
original_datetime = all_values[0][i].get("datetime")
|
||||
# print(original_datetime," ",sum_dc_power," ",all_values[0][i]['dcPower'])
|
||||
dt = to_datetime(original_datetime, to_timezone=self._tz_name, to_naiv=True)
|
||||
# iso_datetime = parser.parse(original_datetime).isoformat() # Konvertiere zu ISO-Format
|
||||
# print()
|
||||
# Optional: 2 Stunden abziehen, um die Zeitanpassung zu testen
|
||||
# adjusted_datetime = parser.parse(original_datetime) - timedelta(hours=2)
|
||||
# print(f"Angepasste Zeitstempel: {adjusted_datetime.isoformat()}")
|
||||
|
||||
if dt < self._forecast_start:
|
||||
# forecast data are too old
|
||||
continue
|
||||
|
||||
sum_dc_power = sum(values[i]["dcPower"] for values in all_values)
|
||||
sum_ac_power = sum(values[i]["power"] for values in all_values)
|
||||
|
||||
forecast = ForecastData(
|
||||
date_time=dt, # Verwende angepassten Zeitstempel
|
||||
dc_power=sum_dc_power,
|
||||
ac_power=sum_ac_power,
|
||||
windspeed_10m=all_values[0][i].get("windspeed_10m"),
|
||||
temperature=all_values[0][i].get("temperature"),
|
||||
)
|
||||
self.forecast_data.append(forecast)
|
||||
|
||||
if len(self.forecast_data) < self.prediction_hours:
|
||||
raise ValueError(
|
||||
f"The forecast must cover at least {self.prediction_hours} hours, "
|
||||
f"but only {len(self.forecast_data)} hours starting from {forecast_start} "
|
||||
f"were predicted."
|
||||
)
|
||||
|
||||
# Adapt forecast start to actual value
|
||||
self._forecast_start = self.forecast_data[0].get_date_time()
|
||||
logger.debug(f"Forecast start adapted to {self._forecast_start}")
|
||||
|
||||
def load_data_from_file(self, filepath: str) -> dict:
|
||||
"""Loads forecast data from a file.
|
||||
|
||||
Args:
|
||||
filepath (str): Path to the file containing the forecast data.
|
||||
|
||||
Returns:
|
||||
data (dict): JSON data containing forecast values.
|
||||
"""
|
||||
with open(filepath, "r") as file:
|
||||
data = json.load(file)
|
||||
return data
|
||||
|
||||
def load_data_from_url(self, url: str) -> dict:
|
||||
"""Loads forecast data from a URL.
|
||||
|
||||
Example:
|
||||
https://api.akkudoktor.net/forecast?lat=52.52&lon=13.405&power=5000&azimuth=-10&tilt=7&powerInvertor=10000&horizont=20,27,22,20&power=4800&azimuth=-90&tilt=7&powerInvertor=10000&horizont=30,30,30,50&power=1400&azimuth=-40&tilt=60&powerInvertor=2000&horizont=60,30,0,30&power=1600&azimuth=5&tilt=45&powerInvertor=1400&horizont=45,25,30,60&past_days=5&cellCoEff=-0.36&inverterEfficiency=0.8&albedo=0.25&timezone=Europe%2FBerlin&hourly=relativehumidity_2m%2Cwindspeed_10m
|
||||
|
||||
Args:
|
||||
url (str): URL of the API providing forecast data.
|
||||
|
||||
Returns:
|
||||
data (dict): JSON data containing forecast values.
|
||||
"""
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
else:
|
||||
data = f"Failed to load data from `{url}`. Status Code: {response.status_code}"
|
||||
logger.error(data)
|
||||
return data
|
||||
|
||||
@cache_in_file() # use binary mode by default as we have python objects not text
|
||||
def load_data_from_url_with_caching(self, url: str, until_date=None) -> dict:
|
||||
"""Loads data from a URL or from the cache if available.
|
||||
|
||||
Args:
|
||||
url (str): URL of the API providing forecast data.
|
||||
|
||||
Returns:
|
||||
data (dict): JSON data containing forecast values.
|
||||
"""
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
logger.debug(f"Data fetched from URL `{url} and cached.")
|
||||
else:
|
||||
data = f"Failed to load data from `{url}`. Status Code: {response.status_code}"
|
||||
logger.error(data)
|
||||
return data
|
||||
|
||||
def get_forecast_data(self):
|
||||
"""Returns the forecast data.
|
||||
|
||||
Returns:
|
||||
list: List of ForecastData objects.
|
||||
"""
|
||||
return self.forecast_data
|
||||
|
||||
def get_temperature_forecast_for_date(
|
||||
self, input_date: Union[datetime, date, str, int, float, None]
|
||||
):
|
||||
"""Returns the temperature forecast for a specific date.
|
||||
|
||||
Args:
|
||||
input_date (str): Date
|
||||
|
||||
Returns:
|
||||
np.array: Array of temperature forecasts.
|
||||
"""
|
||||
if not self._tz_name:
|
||||
raise NotImplementedError(
|
||||
"Processing without PV system timezone info ist not implemented!"
|
||||
)
|
||||
input_date = to_datetime(input_date, to_timezone=self._tz_name, to_naiv=True).date()
|
||||
daily_forecast_obj = [
|
||||
data for data in self.forecast_data if data.get_date_time().date() == input_date
|
||||
]
|
||||
daily_forecast = []
|
||||
for d in daily_forecast_obj:
|
||||
daily_forecast.append(d.get_temperature())
|
||||
|
||||
return np.array(daily_forecast)
|
||||
|
||||
def get_pv_forecast_for_date_range(
|
||||
self,
|
||||
start_date: Union[datetime, date, str, int, float, None],
|
||||
end_date: Union[datetime, date, str, int, float, None],
|
||||
):
|
||||
"""Returns the PV forecast for a date range.
|
||||
|
||||
Args:
|
||||
start_date_str (str): Start date in the format YYYY-MM-DD.
|
||||
end_date_str (str): End date in the format YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: DataFrame containing the forecast data.
|
||||
"""
|
||||
if not self._tz_name:
|
||||
raise NotImplementedError(
|
||||
"Processing without PV system timezone info ist not implemented!"
|
||||
)
|
||||
start_date = to_datetime(start_date, to_timezone=self._tz_name, to_naiv=True).date()
|
||||
end_date = to_datetime(end_date, to_timezone=self._tz_name, to_naiv=True).date()
|
||||
date_range_forecast = []
|
||||
|
||||
for data in self.forecast_data:
|
||||
data_date = data.get_date_time().date()
|
||||
if start_date <= data_date <= end_date:
|
||||
date_range_forecast.append(data)
|
||||
# print(data.get_date_time(), " ", data.get_ac_power())
|
||||
|
||||
ac_power_forecast = np.array([data.get_ac_power() for data in date_range_forecast])
|
||||
|
||||
return np.array(ac_power_forecast)[: self.prediction_hours]
|
||||
|
||||
def get_temperature_for_date_range(
|
||||
self,
|
||||
start_date: Union[datetime, date, str, int, float, None],
|
||||
end_date: Union[datetime, date, str, int, float, None],
|
||||
):
|
||||
"""Returns the temperature forecast for a given date range.
|
||||
|
||||
Args:
|
||||
start_date (datetime | date | str | int | float | None): Start date.
|
||||
end_date (datetime | date | str | int | float | None): End date.
|
||||
|
||||
Returns:
|
||||
np.array: Array containing temperature forecasts for each hour within the date range.
|
||||
"""
|
||||
if not self._tz_name:
|
||||
raise NotImplementedError(
|
||||
"Processing without PV system timezone info ist not implemented!"
|
||||
)
|
||||
start_date = to_datetime(start_date, to_timezone=self._tz_name, to_naiv=True).date()
|
||||
end_date = to_datetime(end_date, to_timezone=self._tz_name, to_naiv=True).date()
|
||||
date_range_forecast = []
|
||||
|
||||
for data in self.forecast_data:
|
||||
data_date = data.get_date_time().date()
|
||||
if start_date <= data_date <= end_date:
|
||||
date_range_forecast.append(data)
|
||||
|
||||
temperature_forecast = [data.get_temperature() for data in date_range_forecast]
|
||||
return np.array(temperature_forecast)[: self.prediction_hours]
|
||||
|
||||
def get_forecast_dataframe(self):
|
||||
"""Converts the forecast data into a Pandas DataFrame.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A DataFrame containing the forecast data with columns for date/time,
|
||||
DC power, AC power, windspeed, and temperature.
|
||||
"""
|
||||
data = [
|
||||
{
|
||||
"date_time": f.get_date_time(),
|
||||
"dc_power": f.get_dc_power(),
|
||||
"ac_power": f.get_ac_power(),
|
||||
"windspeed_10m": f.get_windspeed_10m(),
|
||||
"temperature": f.get_temperature(),
|
||||
}
|
||||
for f in self.forecast_data
|
||||
]
|
||||
|
||||
# Erstelle ein DataFrame
|
||||
df = pd.DataFrame(data)
|
||||
return df
|
||||
|
||||
def get_forecast_start(self) -> datetime:
|
||||
"""Return the start of the forecast data in local timezone.
|
||||
|
||||
Returns:
|
||||
forecast_start (datetime | None): The start datetime or None if no data available.
|
||||
"""
|
||||
if not self._forecast_start:
|
||||
return None
|
||||
return to_datetime(
|
||||
self._forecast_start, to_timezone=self._tz_name, to_naiv=True, to_maxtime=False
|
||||
)
|
||||
|
||||
def report_ac_power_and_measurement(self) -> str:
|
||||
"""Report DC/ AC power, and AC power measurement for each forecast hour.
|
||||
|
||||
For each forecast entry, the time, DC power, forecasted AC power, measured AC power
|
||||
(if available), and the value returned by the `get_ac_power` method is provided.
|
||||
|
||||
Returns:
|
||||
str: The report.
|
||||
"""
|
||||
rep = ""
|
||||
for forecast in self.forecast_data:
|
||||
date_time = forecast.date_time
|
||||
dc_pow = round(forecast.dc_power, 2) if forecast.dc_power else None
|
||||
ac_pow = round(forecast.ac_power, 2) if forecast.ac_power else None
|
||||
ac_pow_measurement = (
|
||||
round(forecast.ac_power_measurement, 2) if forecast.ac_power_measurement else None
|
||||
)
|
||||
get_ac_pow = round(forecast.get_ac_power(), 2) if forecast.get_ac_power() else None
|
||||
rep += (
|
||||
f"Date&Time: {date_time}, DC: {dc_pow}, AC: {ac_pow}, "
|
||||
f"AC measured: {ac_pow_measurement}, AC GET: {get_ac_pow}"
|
||||
"\n"
|
||||
)
|
||||
return rep
|
||||
|
||||
|
||||
# Example of how to use the PVForecast class
|
||||
if __name__ == "__main__":
|
||||
"""Main execution block to demonstrate the use of the PVForecast class.
|
||||
|
||||
Fetches PV power forecast data from a given URL, updates the AC power measurement
|
||||
for the current date/time, and prints the DC and AC power information.
|
||||
"""
|
||||
forecast = PVForecast(
|
||||
prediction_hours=24,
|
||||
url="https://api.akkudoktor.net/forecast?lat=52.52&lon=13.405&"
|
||||
"power=5000&azimuth=-10&tilt=7&powerInvertor=10000&horizont=20,27,22,20&"
|
||||
"power=4800&azimuth=-90&tilt=7&powerInvertor=10000&horizont=30,30,30,50&"
|
||||
"power=1400&azimuth=-40&tilt=60&powerInvertor=2000&horizont=60,30,0,30&"
|
||||
"power=1600&azimuth=5&tilt=45&powerInvertor=1400&horizont=45,25,30,60&"
|
||||
"past_days=5&cellCoEff=-0.36&inverterEfficiency=0.8&albedo=0.25&timezone=Europe%2FBerlin&"
|
||||
"hourly=relativehumidity_2m%2Cwindspeed_10m",
|
||||
)
|
||||
forecast.update_ac_power_measurement(date_time=datetime.now(), ac_power_measurement=1000)
|
||||
print(forecast.report_ac_power_and_measurement())
|
Reference in New Issue
Block a user