mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-11-04 08:46:20 +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