From 9e3c756c5831707250d0a785919dc6f1ba24acf8 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Thu, 10 Oct 2024 17:28:01 +0200 Subject: [PATCH 01/21] Migrate from Flask to FastAPI * FastAPI migration: - Use pydantic model classes as input parameters to the data/calculation classes. - Interface field names changed to constructor parameter names (for simplicity only during transition, should be updated in a followup PR). - Add basic interface requirements (e.g. some values > 0, etc.). * Update tests for new data format. * Python requirement down to 3.9 (TypeGuard no longer needed) * Makefile: Add helpful targets (e.g. development server with reload) --- Dockerfile | 2 +- Makefile | 18 +- NOTICE | 2 +- README.md | 4 +- pyproject.toml | 2 +- requirements.txt | 4 +- single_test_optimization.py | 82 +++--- src/akkudoktoreos/class_akku.py | 56 ++-- src/akkudoktoreos/class_ems.py | 46 +++- src/akkudoktoreos/class_haushaltsgeraet.py | 14 +- src/akkudoktoreos/class_inverter.py | 15 +- src/akkudoktoreos/class_optimize.py | 109 ++++---- src/akkudoktoreosserver/fastapi_server.py | 220 +++++++++++++++ src/akkudoktoreosserver/flask_server.py | 304 --------------------- tests/conftest.py | 4 +- tests/test_class_akku.py | 62 +++-- tests/test_class_ems.py | 46 +++- tests/test_class_optimize.py | 6 +- tests/testdata/optimize_input_1.json | 82 +++--- 19 files changed, 557 insertions(+), 521 deletions(-) create mode 100755 src/akkudoktoreosserver/fastapi_server.py delete mode 100755 src/akkudoktoreosserver/flask_server.py diff --git a/Dockerfile b/Dockerfile index 87ebca6f..7aa72f80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,6 @@ COPY src . USER eos ENTRYPOINT [] -CMD ["python", "-m", "akkudoktoreosserver.flask_server"] +CMD ["python", "-m", "akkudoktoreosserver.fastapi_server"] VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}"] diff --git a/Makefile b/Makefile index a50c2def..283483ab 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Define the targets -.PHONY: help venv pip install dist test docker-run docs clean +.PHONY: help venv pip install dist test docker-run docker-build docs clean format run run-dev # Default target all: help @@ -10,11 +10,13 @@ help: @echo " venv - Set up a Python 3 virtual environment." @echo " pip - Install dependencies from requirements.txt." @echo " pip-dev - Install dependencies from requirements-dev.txt." + @echo " format - Format source code." @echo " install - Install EOS in editable form (development mode) into virtual environment." @echo " docker-run - Run entire setup on docker" @echo " docker-build - Rebuild docker image" @echo " docs - Generate HTML documentation (in build/docs/html/)." - @echo " run - Run flask_server in the virtual environment (needs install before)." + @echo " run - Run FastAPI server in the virtual environment (needs install before)." + @echo " run-dev - Run FastAPI development server in the virtual environment (automatically reloads)." @echo " dist - Create distribution (in dist/)." @echo " clean - Remove generated documentation, distribution and virtual environment." @@ -58,8 +60,12 @@ clean: rm -rf .venv run: - @echo "Starting flask server, please wait..." - .venv/bin/python -m akkudoktoreosserver.flask_server + @echo "Starting FastAPI server, please wait..." + .venv/bin/python -m akkudoktoreosserver.fastapi_server + +run-dev: + @echo "Starting FastAPI development server, please wait..." + .venv/bin/fastapi dev src/akkudoktoreosserver/fastapi_server.py # Target to setup tests. test-setup: pip-dev @@ -70,6 +76,10 @@ test: @echo "Running tests..." .venv/bin/pytest +# Target to format code. +format: + pre-commit run --all-files + # Run entire setup on docker docker-run: @docker compose up --remove-orphans diff --git a/NOTICE b/NOTICE index eb9c1f4d..c40cce91 100644 --- a/NOTICE +++ b/NOTICE @@ -17,7 +17,7 @@ This product may utilize technologies covered under international patents and/or ADDITIONAL ATTRIBUTIONS: The following is a list of licensors and other acknowledgements for third-party software that may be contained within this system: -- Flask, licensed under the BSD License, see https://flask.palletsprojects.com/ +- FastAPI, licensed under the MIT License, see https://fastapi.tiangolo.com/ - NumPy, licensed under the BSD License, see https://numpy.org/ - Requests, licensed under the Apache License 2.0, see https://requests.readthedocs.io/ - matplotlib, licensed under the matplotlib License (a variant of the Python Software Foundation License), see https://matplotlib.org/ diff --git a/README.md b/README.md index 9b3c5063..1879143f 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ source .venv/bin/activate ## Usage Adjust `config.py`. -To use the system, run `flask_server.py`, which starts the server: +To use the system, run `fastapi_server.py`, which starts the server: ```bash -./flask_server.py +./fastapi_server.py ``` ## Classes and Functionalities diff --git a/pyproject.toml b/pyproject.toml index 0256f066..9d1f7647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ description = "This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period." readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.10" +requires-python = ">=3.9" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", diff --git a/requirements.txt b/requirements.txt index fd82718f..5ffc9753 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ numpy==2.1.2 matplotlib==3.9.2 -flask==3.0.3 +fastapi[standard]==0.115.0 +uvicorn==0.31.1 +pydantic==2.9.2 scikit-learn==1.5.2 deap==1.4.1 requests==2.32.3 diff --git a/single_test_optimization.py b/single_test_optimization.py index 679d0a9a..2d8fa897 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -3,7 +3,7 @@ import json # Import necessary modules from the project -from akkudoktoreos.class_optimize import optimization_problem +from akkudoktoreos.class_optimize import OptimizationParameters, optimization_problem start_hour = 10 @@ -219,47 +219,49 @@ start_solution = None # Define parameters for the optimization problem -parameter = { - # Cost of storing energy in battery (per Wh) - "preis_euro_pro_wh_akku": 10e-05, - # Initial state of charge (SOC) of PV battery (%) - "pv_soc": 80, - # Battery capacity (in Wh) - "pv_akku_cap": 26400, - # Yearly energy consumption (in Wh) - "year_energy": 4100000, - # Feed-in tariff for exporting electricity (per Wh) - "einspeiseverguetung_euro_pro_wh": 7e-05, - # Maximum heating power (in W) - "max_heizleistung": 1000, - # Overall load on the system - "gesamtlast": gesamtlast, - # PV generation forecast (48 hours) - "pv_forecast": pv_forecast, +parameters = { + "ems": { + # Cost of storing energy in battery (per Wh) + "preis_euro_pro_wh_akku": 10e-05, + # Feed-in tariff for exporting electricity (per Wh) + "einspeiseverguetung_euro_pro_wh": 7e-05, + # Overall load on the system + "gesamtlast": gesamtlast, + # PV generation forecast (48 hours) + "pv_prognose_wh": pv_forecast, + # Electricity price forecast (48 hours) + "strompreis_euro_pro_wh": strompreis_euro_pro_wh, + }, + "pv_akku": { + # Battery capacity (in Wh) + "kapazitaet_wh": 26400, + # Initial state of charge (SOC) of PV battery (%) + "start_soc_prozent": 80, + # Minimum Soc PV Battery + "min_soc_prozent": 15, + }, + "eauto": { + # Minimum SOC for electric car + "min_soc_prozent": 80, + # Electric car battery capacity (Wh) + "kapazitaet_wh": 60000, + # Charging efficiency of the electric car + "lade_effizienz": 0.95, + # Charging power of the electric car (W) + "max_ladeleistung_w": 11040, + # Current SOC of the electric car (%) + "start_soc_prozent": 5, + }, + "spuelmaschine": { + # Household appliance consumption (Wh) + "verbrauch_wh": 5000, + # Duration of appliance usage (hours) + "dauer_h": 2, + }, # Temperature forecast (48 hours) "temperature_forecast": temperature_forecast, - # Electricity price forecast (48 hours) - "strompreis_euro_pro_wh": strompreis_euro_pro_wh, - # Minimum SOC for electric car - "eauto_min_soc": 80, - # Electric car battery capacity (Wh) - "eauto_cap": 60000, - # Charging efficiency of the electric car - "eauto_charge_efficiency": 0.95, - # Charging power of the electric car (W) - "eauto_charge_power": 11040, - # Current SOC of the electric car (%) - "eauto_soc": 5, - # Current PV power generation (W) - "pvpowernow": 211.137503624, # Initial solution for the optimization "start_solution": start_solution, - # Household appliance consumption (Wh) - "haushaltsgeraet_wh": 5000, - # Duration of appliance usage (hours) - "haushaltsgeraet_dauer": 2, - # Minimum Soc PV Battery - "min_soc_prozent": 15, } # Initialize the optimization problem @@ -268,7 +270,9 @@ ) # Perform the optimisation based on the provided parameters and start hour -ergebnis = opt_class.optimierung_ems(parameter=parameter, start_hour=start_hour) +ergebnis = opt_class.optimierung_ems( + parameters=OptimizationParameters(**parameters), start_hour=start_hour +) # Print or visualize the result # pprint(ergebnis) diff --git a/src/akkudoktoreos/class_akku.py b/src/akkudoktoreos/class_akku.py index 70d3513c..5c7364a3 100644 --- a/src/akkudoktoreos/class_akku.py +++ b/src/akkudoktoreos/class_akku.py @@ -1,32 +1,48 @@ +from typing import Optional + import numpy as np +from pydantic import BaseModel, Field + + +class BaseAkkuParameters(BaseModel): + kapazitaet_wh: float = Field(gt=0) + lade_effizienz: float = Field(0.88, gt=0, le=1) + entlade_effizienz: float = Field(0.88, gt=0, le=1) + max_ladeleistung_w: Optional[float] = Field(None, gt=0) + start_soc_prozent: float = Field(0, ge=0, le=100) + min_soc_prozent: int = Field(0, ge=0, le=100) + max_soc_prozent: int = Field(100, ge=0, le=100) + + +class PVAkkuParameters(BaseAkkuParameters): + max_ladeleistung_w: Optional[float] = 5000 + + +class EAutoParameters(BaseAkkuParameters): + entlade_effizienz: float = 1.0 class PVAkku: - def __init__( - self, - kapazitaet_wh=None, - hours=None, - lade_effizienz=0.88, - entlade_effizienz=0.88, - max_ladeleistung_w=None, - start_soc_prozent=0, - min_soc_prozent=0, - max_soc_prozent=100, - ): + def __init__(self, parameters: BaseAkkuParameters, hours: int = 24): # Battery capacity in Wh - self.kapazitaet_wh = kapazitaet_wh + self.kapazitaet_wh = parameters.kapazitaet_wh # Initial state of charge in Wh - self.start_soc_prozent = start_soc_prozent - self.soc_wh = (start_soc_prozent / 100) * kapazitaet_wh - self.hours = hours if hours is not None else 24 # Default to 24 hours if not specified + self.start_soc_prozent = parameters.start_soc_prozent + self.soc_wh = (parameters.start_soc_prozent / 100) * parameters.kapazitaet_wh + self.hours = hours self.discharge_array = np.full(self.hours, 1) self.charge_array = np.full(self.hours, 1) # Charge and discharge efficiency - self.lade_effizienz = lade_effizienz - self.entlade_effizienz = entlade_effizienz - self.max_ladeleistung_w = max_ladeleistung_w if max_ladeleistung_w else self.kapazitaet_wh - self.min_soc_prozent = min_soc_prozent - self.max_soc_prozent = max_soc_prozent + self.lade_effizienz = parameters.lade_effizienz + self.entlade_effizienz = parameters.entlade_effizienz + self.max_ladeleistung_w = ( + parameters.max_ladeleistung_w if parameters.max_ladeleistung_w else self.kapazitaet_wh + ) + # Only assign for storage battery + self.min_soc_prozent = ( + parameters.min_soc_prozent if isinstance(parameters, PVAkkuParameters) else 0 + ) + self.max_soc_prozent = parameters.max_soc_prozent # Calculate min and max SoC in Wh self.min_soc_wh = (self.min_soc_prozent / 100) * self.kapazitaet_wh self.max_soc_wh = (self.max_soc_prozent / 100) * self.kapazitaet_wh diff --git a/src/akkudoktoreos/class_ems.py b/src/akkudoktoreos/class_ems.py index f01f4889..d30e4dd4 100644 --- a/src/akkudoktoreos/class_ems.py +++ b/src/akkudoktoreos/class_ems.py @@ -2,24 +2,46 @@ from typing import Dict, List, Optional, Union import numpy as np +from pydantic import BaseModel, model_validator +from typing_extensions import Self + +from akkudoktoreos.class_akku import PVAkku +from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet +from akkudoktoreos.class_inverter import Wechselrichter + + +class EnergieManagementSystemParameters(BaseModel): + pv_prognose_wh: list[float] + strompreis_euro_pro_wh: list[float] + einspeiseverguetung_euro_pro_wh: float + preis_euro_pro_wh_akku: float + gesamtlast: list[float] + + @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 + ): + raise ValueError("Input lists have different lenghts") + return self class EnergieManagementSystem: def __init__( self, - pv_prognose_wh: Optional[np.ndarray] = None, - strompreis_euro_pro_wh: Optional[np.ndarray] = None, - einspeiseverguetung_euro_pro_wh: Optional[np.ndarray] = None, - eauto: Optional[object] = None, - gesamtlast: Optional[np.ndarray] = None, - haushaltsgeraet: Optional[object] = None, - wechselrichter: Optional[object] = None, + parameters: EnergieManagementSystemParameters, + eauto: Optional[PVAkku] = None, + haushaltsgeraet: Optional[Haushaltsgeraet] = None, + wechselrichter: Optional[Wechselrichter] = None, ): self.akku = wechselrichter.akku - self.gesamtlast = gesamtlast - self.pv_prognose_wh = pv_prognose_wh - self.strompreis_euro_pro_wh = strompreis_euro_pro_wh - self.einspeiseverguetung_euro_pro_wh = einspeiseverguetung_euro_pro_wh + 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 = np.full( + len(self.gesamtlast), parameters.einspeiseverguetung_euro_pro_wh, float + ) self.eauto = eauto self.haushaltsgeraet = haushaltsgeraet self.wechselrichter = wechselrichter @@ -102,7 +124,7 @@ def simuliere(self, start_stunde: int) -> dict: netzbezug * self.strompreis_euro_pro_wh[stunde] ) einnahmen_euro_pro_stunde[stunde_since_now] = ( - netzeinspeisung * self.einspeiseverguetung_euro_pro_wh[stunde] + netzeinspeisung * self.einspeiseverguetung_euro_pro_wh_arr[stunde] ) # Akku SOC tracking diff --git a/src/akkudoktoreos/class_haushaltsgeraet.py b/src/akkudoktoreos/class_haushaltsgeraet.py index 7fbc64df..8d3c3b6b 100644 --- a/src/akkudoktoreos/class_haushaltsgeraet.py +++ b/src/akkudoktoreos/class_haushaltsgeraet.py @@ -1,11 +1,19 @@ import numpy as np +from pydantic import BaseModel, Field + + +class HaushaltsgeraetParameters(BaseModel): + verbrauch_wh: float = Field(gt=0) + dauer_h: int = Field(gt=0) class Haushaltsgeraet: - def __init__(self, hours=None, verbrauch_wh=None, dauer_h=None): + def __init__(self, parameters: HaushaltsgeraetParameters, hours=24): self.hours = hours # Total duration for which the planning is done - self.verbrauch_wh = verbrauch_wh # Total energy consumption of the device in kWh - self.dauer_h = dauer_h # Duration of use in hours + self.verbrauch_wh = ( + parameters.verbrauch_wh # Total energy consumption of the device in kWh + ) + self.dauer_h = parameters.dauer_h # Duration of use in hours self.lastkurve = np.zeros(self.hours) # Initialize the load curve with zeros def set_startzeitpunkt(self, start_hour, global_start_hour=0): diff --git a/src/akkudoktoreos/class_inverter.py b/src/akkudoktoreos/class_inverter.py index 382ba6be..3b893140 100644 --- a/src/akkudoktoreos/class_inverter.py +++ b/src/akkudoktoreos/class_inverter.py @@ -1,6 +1,17 @@ +from pydantic import BaseModel, Field + +from akkudoktoreos.class_akku import PVAkku + + +class WechselrichterParameters(BaseModel): + max_leistung_wh: float = Field(10000, gt=0) + + class Wechselrichter: - def __init__(self, max_leistung_wh, akku): - self.max_leistung_wh = max_leistung_wh # Maximum power that the inverter can handle + def __init__(self, parameters: WechselrichterParameters, akku: PVAkku): + self.max_leistung_wh = ( + parameters.max_leistung_wh # Maximum power that the inverter can handle + ) self.akku = akku # Connection to a battery object def energie_verarbeiten(self, erzeugung, verbrauch, hour): diff --git a/src/akkudoktoreos/class_optimize.py b/src/akkudoktoreos/class_optimize.py index 64715cc1..9c1fd5e7 100644 --- a/src/akkudoktoreos/class_optimize.py +++ b/src/akkudoktoreos/class_optimize.py @@ -1,17 +1,42 @@ import random -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Optional, Tuple import numpy as np from deap import algorithms, base, creator, tools - -from akkudoktoreos.class_akku import PVAkku -from akkudoktoreos.class_ems import EnergieManagementSystem -from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet -from akkudoktoreos.class_inverter import Wechselrichter +from pydantic import BaseModel, model_validator +from typing_extensions import Self + +from akkudoktoreos.class_akku import EAutoParameters, PVAkku, PVAkkuParameters +from akkudoktoreos.class_ems import ( + EnergieManagementSystem, + EnergieManagementSystemParameters, +) +from akkudoktoreos.class_haushaltsgeraet import ( + Haushaltsgeraet, + HaushaltsgeraetParameters, +) +from akkudoktoreos.class_inverter import Wechselrichter, WechselrichterParameters from akkudoktoreos.config import moegliche_ladestroeme_in_prozent from akkudoktoreos.visualize import visualisiere_ergebnisse +class OptimizationParameters(BaseModel): + ems: EnergieManagementSystemParameters + pv_akku: PVAkkuParameters + wechselrichter: WechselrichterParameters = WechselrichterParameters() + eauto: EAutoParameters + spuelmaschine: Optional[HaushaltsgeraetParameters] = None + temperature_forecast: list[float] + start_solution: Optional[list[float]] = None + + @model_validator(mode="after") + def validate_list_length(self) -> Self: + arr_length = len(self.ems.pv_prognose_wh) + if arr_length != len(self.temperature_forecast): + raise ValueError("Input lists have different lenghts") + return self + + class optimization_problem: def __init__( self, @@ -35,8 +60,8 @@ def __init__( random.seed(fixed_seed) def split_individual( - self, individual: List[float] - ) -> Tuple[List[int], List[float], Optional[int]]: + self, individual: list[float] + ) -> Tuple[list[int], list[float], Optional[int]]: """ Split the individual solution into its components: 1. Discharge hours (binary), @@ -52,7 +77,7 @@ def split_individual( ) return discharge_hours_bin, eautocharge_hours_float, spuelstart_int - def setup_deap_environment(self, opti_param: Dict[str, Any], start_hour: int) -> None: + def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> None: """ Set up the DEAP environment with fitness and individual creation rules. """ @@ -99,8 +124,8 @@ def setup_deap_environment(self, opti_param: Dict[str, Any], start_hour: int) -> self.toolbox.register("select", tools.selTournament, tournsize=3) def evaluate_inner( - self, individual: List[float], ems: EnergieManagementSystem, start_hour: int - ) -> Dict[str, Any]: + self, individual: list[float], ems: EnergieManagementSystem, start_hour: int + ) -> dict[str, Any]: """ Internal evaluation function that simulates the energy management system (EMS) using the provided individual solution. @@ -121,9 +146,9 @@ def evaluate_inner( def evaluate( self, - individual: List[float], + individual: list[float], ems: EnergieManagementSystem, - parameter: Dict[str, Any], + parameters: OptimizationParameters, start_hour: int, worst_case: bool, ) -> Tuple[float]: @@ -159,7 +184,7 @@ def evaluate( ) # Penalty for not meeting the minimum SOC (State of Charge) requirement - if parameter["eauto_min_soc"] - ems.eauto.ladezustand_in_prozent() <= 0.0: + if parameters.eauto.min_soc_prozent - ems.eauto.ladezustand_in_prozent() <= 0.0: gesamtbilanz += sum( self.strafe for ladeleistung in eautocharge_hours_float if ladeleistung != 0.0 ) @@ -167,15 +192,16 @@ def evaluate( individual.extra_data = ( o["Gesamtbilanz_Euro"], o["Gesamt_Verluste"], - parameter["eauto_min_soc"] - ems.eauto.ladezustand_in_prozent(), + parameters.eauto.min_soc_prozent - ems.eauto.ladezustand_in_prozent(), ) # Adjust total balance with battery value and penalties for unmet SOC - restwert_akku = ems.akku.aktueller_energieinhalt() * parameter["preis_euro_pro_wh_akku"] + restwert_akku = ems.akku.aktueller_energieinhalt() * parameters.ems.preis_euro_pro_wh_akku gesamtbilanz += ( max( 0, - (parameter["eauto_min_soc"] - ems.eauto.ladezustand_in_prozent()) * self.strafe, + (parameters.eauto.min_soc_prozent - ems.eauto.ladezustand_in_prozent()) + * self.strafe, ) - restwert_akku ) @@ -183,8 +209,8 @@ def evaluate( return (gesamtbilanz,) def optimize( - self, start_solution: Optional[List[float]] = None, ngen: int = 400 - ) -> Tuple[Any, Dict[str, List[Any]]]: + self, start_solution: Optional[list[float]] = None, ngen: int = 400 + ) -> Tuple[Any, dict[str, list[Any]]]: """Run the optimization process using a genetic algorithm.""" population = self.toolbox.population(n=300) hof = tools.HallOfFame(1) @@ -225,58 +251,43 @@ def optimize( def optimierung_ems( self, - parameter: Optional[Dict[str, Any]] = None, + parameters: OptimizationParameters, start_hour: Optional[int] = None, worst_case: bool = False, startdate: Optional[Any] = None, # startdate is not used! *, ngen: int = 400, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Perform EMS (Energy Management System) optimization and visualize results. """ - einspeiseverguetung_euro_pro_wh = np.full( - self.prediction_hours, parameter["einspeiseverguetung_euro_pro_wh"] - ) - # Initialize PV and EV batteries akku = PVAkku( - kapazitaet_wh=parameter["pv_akku_cap"], + parameters.pv_akku, hours=self.prediction_hours, - start_soc_prozent=parameter["pv_soc"], - min_soc_prozent=parameter["min_soc_prozent"], - max_ladeleistung_w=5000, ) akku.set_charge_per_hour(np.full(self.prediction_hours, 1)) eauto = PVAkku( - kapazitaet_wh=parameter["eauto_cap"], + parameters.eauto, hours=self.prediction_hours, - lade_effizienz=parameter["eauto_charge_efficiency"], - entlade_effizienz=1.0, - max_ladeleistung_w=parameter["eauto_charge_power"], - start_soc_prozent=parameter["eauto_soc"], ) eauto.set_charge_per_hour(np.full(self.prediction_hours, 1)) # Initialize household appliance if applicable spuelmaschine = ( Haushaltsgeraet( + parameters=parameters.spuelmaschine, hours=self.prediction_hours, - verbrauch_wh=parameter["haushaltsgeraet_wh"], - dauer_h=parameter["haushaltsgeraet_dauer"], ) - if parameter["haushaltsgeraet_dauer"] > 0 + if parameters.spuelmaschine is not None else None ) # Initialize the inverter and energy management system - wr = Wechselrichter(10000, akku) + wr = Wechselrichter(parameters.wechselrichter, akku) ems = EnergieManagementSystem( - gesamtlast=parameter["gesamtlast"], - pv_prognose_wh=parameter["pv_forecast"], - strompreis_euro_pro_wh=parameter["strompreis_euro_pro_wh"], - einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, + parameters.ems, eauto=eauto, haushaltsgeraet=spuelmaschine, wechselrichter=wr, @@ -286,9 +297,9 @@ def optimierung_ems( self.setup_deap_environment({"haushaltsgeraete": 1 if spuelmaschine else 0}, start_hour) self.toolbox.register( "evaluate", - lambda ind: self.evaluate(ind, ems, parameter, start_hour, worst_case), + lambda ind: self.evaluate(ind, ems, parameters, start_hour, worst_case), ) - start_solution, extra_data = self.optimize(parameter["start_solution"], ngen=ngen) + start_solution, extra_data = self.optimize(parameters.start_solution, ngen=ngen) # Perform final evaluation on the best solution o = self.evaluate_inner(start_solution, ems, start_hour) @@ -298,16 +309,16 @@ def optimierung_ems( # Visualize the results visualisiere_ergebnisse( - parameter["gesamtlast"], - parameter["pv_forecast"], - parameter["strompreis_euro_pro_wh"], + parameters.ems.gesamtlast, + parameters.ems.pv_prognose_wh, + parameters.ems.strompreis_euro_pro_wh, o, discharge_hours_bin, eautocharge_hours_float, - parameter["temperature_forecast"], + parameters.temperature_forecast, start_hour, self.prediction_hours, - einspeiseverguetung_euro_pro_wh, + ems.einspeiseverguetung_euro_pro_wh_arr, extra_data=extra_data, ) diff --git a/src/akkudoktoreosserver/fastapi_server.py b/src/akkudoktoreosserver/fastapi_server.py new file mode 100755 index 00000000..56677ed5 --- /dev/null +++ b/src/akkudoktoreosserver/fastapi_server.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +import os +from datetime import datetime +from typing import Annotated, Any, Optional + +import matplotlib +import uvicorn + +# Sets the Matplotlib backend to 'Agg' for rendering plots in environments without a display +matplotlib.use("Agg") + +import pandas as pd +from fastapi import FastAPI, Query +from fastapi.responses import FileResponse, RedirectResponse + +from akkudoktoreos.class_load import LoadForecast +from akkudoktoreos.class_load_container import Gesamtlast +from akkudoktoreos.class_load_corrector import LoadPredictionAdjuster +from akkudoktoreos.class_optimize import OptimizationParameters, optimization_problem +from akkudoktoreos.class_pv_forecast import PVForecast +from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast +from akkudoktoreos.config import ( + get_start_enddate, + optimization_hours, + output_dir, + prediction_hours, +) + +app = FastAPI( + title="Akkudoktor-EOS", + description="This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.", + summary="Comprehensive solution for simulating and optimizing an energy system based on renewable energy sources", + version="0.0.1", + license_info={ + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html", + }, +) + +opt_class = optimization_problem( + prediction_hours=prediction_hours, strafe=10, optimization_hours=optimization_hours +) + + +@app.get("/strompreis") +def fastapi_strompreis(): + # Get the current date and the end date based on prediction hours + date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) + filepath = os.path.join( + r"test_data", r"strompreise_akkudokAPI.json" + ) # Adjust the path to the JSON file + price_forecast = HourlyElectricityPriceForecast( + source=f"https://api.akkudoktor.net/prices?start={date_now}&end={date}", + prediction_hours=prediction_hours, + ) + specific_date_prices = price_forecast.get_price_for_daterange( + date_now, date + ) # Fetch prices for the specified date range + return specific_date_prices.tolist() + + +# Endpoint to handle total load calculation based on the latest measured data +@app.post("/gesamtlast") +def fastapi_gesamtlast(year_energy: float, measured_data: list[dict[str, Any]], hours: int = 48): + prediction_hours = hours + + # Measured data in JSON format + measured_data_df = pd.DataFrame(measured_data) + measured_data_df["time"] = pd.to_datetime(measured_data_df["time"]) + + # Ensure datetime has timezone info for accurate calculations + if measured_data_df["time"].dt.tz is None: + measured_data_df["time"] = measured_data_df["time"].dt.tz_localize("Europe/Berlin") + else: + measured_data_df["time"] = measured_data_df["time"].dt.tz_convert("Europe/Berlin") + + # Remove timezone info after conversion to simplify further processing + measured_data_df["time"] = measured_data_df["time"].dt.tz_localize(None) + + # Instantiate LoadForecast and generate forecast data + file_path = os.path.join("data", "load_profiles.npz") + lf = LoadForecast(filepath=file_path, year_energy=year_energy) + forecast_list = [] + + # Generate daily forecasts for the date range based on measured data + for single_date in pd.date_range( + measured_data_df["time"].min().date(), measured_data_df["time"].max().date() + ): + date_str = single_date.strftime("%Y-%m-%d") + daily_forecast = lf.get_daily_stats(date_str) + mean_values = daily_forecast[0] + 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 + predicted_data = pd.concat(forecast_list, ignore_index=True) + + # Create LoadPredictionAdjuster instance to adjust the predictions based on measured data + adjuster = LoadPredictionAdjuster(measured_data_df, predicted_data, lf) + adjuster.calculate_weighted_mean() # Calculate weighted mean for adjustment + adjuster.adjust_predictions() # Adjust predictions based on measured data + future_predictions = adjuster.predict_next_hours(prediction_hours) # Predict future load + + # Extract household power predictions + leistung_haushalt = future_predictions["Adjusted Pred"].values + gesamtlast = Gesamtlast(prediction_hours=prediction_hours) + gesamtlast.hinzufuegen( + "Haushalt", leistung_haushalt + ) # Add household load to total load calculation + + # Calculate the total load + last = gesamtlast.gesamtlast_berechnen() # Compute total load + return last.tolist() + + +@app.get("/gesamtlast_simple") +def fastapi_gesamtlast_simple(year_energy: float): + date_now, date = get_start_enddate( + prediction_hours, startdate=datetime.now().date() + ) # Get the current date and prediction end date + + ############### + # Load Forecast + ############### + server_dir = os.path.dirname(os.path.realpath(__file__)) + file_path = os.path.join(server_dir, "data", "load_profiles.npz") + + print(file_path) + + lf = LoadForecast( + filepath=file_path, year_energy=year_energy + ) # Instantiate LoadForecast with specified parameters + leistung_haushalt = lf.get_stats_for_date_range(date_now, date)[ + 0 + ] # Get expected household load for the date range + + gesamtlast = Gesamtlast(prediction_hours=prediction_hours) # Create Gesamtlast instance + gesamtlast.hinzufuegen( + "Haushalt", leistung_haushalt + ) # Add household load to total load calculation + + # ############### + # # WP (Heat Pump) + # ############## + # leistung_wp = wp.simulate_24h(temperature_forecast) # Simulate heat pump load for 24 hours + # gesamtlast.hinzufuegen("Heatpump", leistung_wp) # Add heat pump load to total load calculation + + last = gesamtlast.gesamtlast_berechnen() # Calculate total load + print(last) # Output total load + return last.tolist() # Return total load as JSON + + +@app.get("/pvforecast") +def fastapi_pvprognose(url: str, ac_power_measurement: Optional[float] = None): + date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) + + ############### + # PV Forecast + ############### + PVforecast = PVForecast( + prediction_hours=prediction_hours, url=url + ) # Instantiate PVForecast with given parameters + if ac_power_measurement is not None: + PVforecast.update_ac_power_measurement( + date_time=datetime.now(), + ac_power_measurement=ac_power_measurement, + ) # Update measurement + + # Get PV forecast and temperature forecast for the specified date range + pv_forecast = PVforecast.get_pv_forecast_for_date_range(date_now, date) + temperature_forecast = PVforecast.get_temperature_for_date_range(date_now, date) + + # Return both forecasts as a JSON response + ret = { + "temperature": temperature_forecast.tolist(), + "pvpower": pv_forecast.tolist(), + } + return ret + + +@app.post("/optimize") +def fastapi_optimize( + parameters: OptimizationParameters, start_hour: Annotated[int, Query()] = datetime.now().hour +): + # Perform optimization simulation + result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour) + print(result) + # convert to JSON (None accepted by dumps) + return result + + +@app.get("/visualization_results.pdf") +def get_pdf(): + # Endpoint to serve the generated PDF with visualization results + return FileResponse(os.path.join(output_dir, "visualization_results.pdf")) + + +@app.get("/site-map") +def site_map(): + return RedirectResponse(url="/docs") + + +@app.get("/") +def root(): + # Redirect the root URL to the site map + return RedirectResponse(url="/docs") + + +if __name__ == "__main__": + # Set host and port from environment variables or defaults + host = os.getenv("FASTAPI_RUN_HOST", "0.0.0.0") + port = os.getenv("FASTAPI_RUN_PORT", 8503) + try: + uvicorn.run(app, host=host, port=int(port)) # Run the FastAPI application + except Exception as e: + print( + f"Could not bind to host {host}:{port}. Error: {e}" + ) # Error handling for binding issues diff --git a/src/akkudoktoreosserver/flask_server.py b/src/akkudoktoreosserver/flask_server.py deleted file mode 100755 index 4544a151..00000000 --- a/src/akkudoktoreosserver/flask_server.py +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env python3 - -import os -from datetime import datetime -from typing import Any, TypeGuard - -import matplotlib - -# Sets the Matplotlib backend to 'Agg' for rendering plots in environments without a display -matplotlib.use("Agg") - -import pandas as pd -from flask import Flask, jsonify, redirect, request, send_from_directory, url_for - -from akkudoktoreos.class_load import LoadForecast -from akkudoktoreos.class_load_container import Gesamtlast -from akkudoktoreos.class_load_corrector import LoadPredictionAdjuster -from akkudoktoreos.class_optimize import optimization_problem -from akkudoktoreos.class_pv_forecast import PVForecast -from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast -from akkudoktoreos.config import ( - get_start_enddate, - optimization_hours, - output_dir, - prediction_hours, -) - -app = Flask(__name__) - -opt_class = optimization_problem( - prediction_hours=prediction_hours, strafe=10, optimization_hours=optimization_hours -) - - -def isfloat(num: Any) -> TypeGuard[float]: - """Check if a given input can be converted to float.""" - if num is None: - return False - - if isinstance(num, str): - num = num.strip() # Strip any surrounding whitespace - - try: - float_value = float(num) - return not ( - float_value == float("inf") - or float_value == float("-inf") - or float_value != float_value - ) # Excludes NaN or Infinity - except (ValueError, TypeError): - return False - - -@app.route("/strompreis", methods=["GET"]) -def flask_strompreis(): - # Get the current date and the end date based on prediction hours - date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) - filepath = os.path.join( - r"test_data", r"strompreise_akkudokAPI.json" - ) # Adjust the path to the JSON file - price_forecast = HourlyElectricityPriceForecast( - source=f"https://api.akkudoktor.net/prices?start={date_now}&end={date}", - prediction_hours=prediction_hours, - ) - specific_date_prices = price_forecast.get_price_for_daterange( - date_now, date - ) # Fetch prices for the specified date range - return jsonify(specific_date_prices.tolist()) - - -# Endpoint to handle total load calculation based on the latest measured data -@app.route("/gesamtlast", methods=["POST"]) -def flask_gesamtlast(): - # Retrieve data from the JSON body - data = request.get_json() - - # Extract year_energy and prediction_hours from the request JSON - year_energy = float(data.get("year_energy")) - prediction_hours = int(data.get("hours", 48)) # Default to 48 hours if not specified - - # Measured data in JSON format - measured_data_json = data.get("measured_data") - measured_data = pd.DataFrame(measured_data_json) - measured_data["time"] = pd.to_datetime(measured_data["time"]) - - # Ensure datetime has timezone info for accurate calculations - if measured_data["time"].dt.tz is None: - measured_data["time"] = measured_data["time"].dt.tz_localize("Europe/Berlin") - else: - measured_data["time"] = measured_data["time"].dt.tz_convert("Europe/Berlin") - - # Remove timezone info after conversion to simplify further processing - measured_data["time"] = measured_data["time"].dt.tz_localize(None) - - # Instantiate LoadForecast and generate forecast data - file_path = os.path.join("data", "load_profiles.npz") - lf = LoadForecast(filepath=file_path, year_energy=year_energy) - forecast_list = [] - - # Generate daily forecasts for the date range based on measured data - for single_date in pd.date_range( - measured_data["time"].min().date(), measured_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] - 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 - predicted_data = pd.concat(forecast_list, ignore_index=True) - - # Create LoadPredictionAdjuster instance to adjust the predictions based on measured data - adjuster = LoadPredictionAdjuster(measured_data, predicted_data, lf) - adjuster.calculate_weighted_mean() # Calculate weighted mean for adjustment - adjuster.adjust_predictions() # Adjust predictions based on measured data - future_predictions = adjuster.predict_next_hours(prediction_hours) # Predict future load - - # Extract household power predictions - leistung_haushalt = future_predictions["Adjusted Pred"].values - gesamtlast = Gesamtlast(prediction_hours=prediction_hours) - gesamtlast.hinzufuegen( - "Haushalt", leistung_haushalt - ) # Add household load to total load calculation - - # Calculate the total load - last = gesamtlast.gesamtlast_berechnen() # Compute total load - return jsonify(last.tolist()) - - -@app.route("/gesamtlast_simple", methods=["GET"]) -def flask_gesamtlast_simple(): - if request.method == "GET": - year_energy = float( - request.args.get("year_energy") - ) # Get annual energy value from query parameters - date_now, date = get_start_enddate( - prediction_hours, startdate=datetime.now().date() - ) # Get the current date and prediction end date - - ############### - # Load Forecast - ############### - server_dir = os.path.dirname(os.path.realpath(__file__)) - file_path = os.path.join(server_dir, "data", "load_profiles.npz") - - print(file_path) - - lf = LoadForecast( - filepath=file_path, year_energy=year_energy - ) # Instantiate LoadForecast with specified parameters - leistung_haushalt = lf.get_stats_for_date_range(date_now, date)[ - 0 - ] # Get expected household load for the date range - - gesamtlast = Gesamtlast(prediction_hours=prediction_hours) # Create Gesamtlast instance - gesamtlast.hinzufuegen( - "Haushalt", leistung_haushalt - ) # Add household load to total load calculation - - # ############### - # # WP (Heat Pump) - # ############## - # leistung_wp = wp.simulate_24h(temperature_forecast) # Simulate heat pump load for 24 hours - # gesamtlast.hinzufuegen("Heatpump", leistung_wp) # Add heat pump load to total load calculation - - last = gesamtlast.gesamtlast_berechnen() # Calculate total load - print(last) # Output total load - return jsonify(last.tolist()) # Return total load as JSON - - -@app.route("/pvforecast", methods=["GET"]) -def flask_pvprognose(): - if request.method == "GET": - # Retrieve URL and AC power measurement from query parameters - url = request.args.get("url") - ac_power_measurement = request.args.get("ac_power_measurement") - date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) - - ############### - # PV Forecast - ############### - PVforecast = PVForecast( - prediction_hours=prediction_hours, url=url - ) # Instantiate PVForecast with given parameters - if isfloat(ac_power_measurement): # Check if the AC power measurement is a valid float - PVforecast.update_ac_power_measurement( - date_time=datetime.now(), - ac_power_measurement=float(ac_power_measurement), - ) # Update measurement - - # Get PV forecast and temperature forecast for the specified date range - pv_forecast = PVforecast.get_pv_forecast_for_date_range(date_now, date) - temperature_forecast = PVforecast.get_temperature_for_date_range(date_now, date) - - # Return both forecasts as a JSON response - ret = { - "temperature": temperature_forecast.tolist(), - "pvpower": pv_forecast.tolist(), - } - return jsonify(ret) - - -@app.route("/optimize", methods=["POST"]) -def flask_optimize(): - with open( - "C:\\Users\\drbac\\OneDrive\\Dokumente\\PythonPojects\\EOS\\debug_output.txt", - "a", - ) as f: - f.write("Test\n") - - if request.method == "POST": - from datetime import datetime - - # Retrieve optimization parameters from the request JSON - parameter = request.json - - # Check for required parameters - required_parameters = [ - "preis_euro_pro_wh_akku", - "strompreis_euro_pro_wh", - "gesamtlast", - "pv_akku_cap", - "einspeiseverguetung_euro_pro_wh", - "pv_forecast", - "temperature_forecast", - "eauto_min_soc", - "eauto_cap", - "eauto_charge_efficiency", - "eauto_charge_power", - "eauto_soc", - "pv_soc", - "start_solution", - "haushaltsgeraet_dauer", - "haushaltsgeraet_wh", - ] - # Identify any missing parameters - missing_params = [p for p in required_parameters if p not in parameter] - if missing_params: - return jsonify( - {"error": f"Missing parameter: {', '.join(missing_params)}"} - ), 400 # Return error for missing parameters - - # Optional min SoC PV Battery - if "min_soc_prozent" not in parameter: - parameter["min_soc_prozent"] = None - - # Perform optimization simulation - result = opt_class.optimierung_ems(parameter=parameter, start_hour=datetime.now().hour) - print(result) - # convert to JSON (None accepted by dumps) - return jsonify(result) - - -@app.route("/visualization_results.pdf") -def get_pdf(): - # Endpoint to serve the generated PDF with visualization results - return send_from_directory( - os.path.abspath(output_dir), "visualization_results.pdf" - ) # Adjust the directory if needed - - -@app.route("/site-map") -def site_map(): - # Function to generate a site map of valid routes in the application - def print_links(links): - content = "

Valid routes

" - return content - - # Check if the route has no empty parameters - def has_no_empty_params(rule): - defaults = rule.defaults if rule.defaults is not None else () - arguments = rule.arguments if rule.arguments is not None else () - return len(defaults) >= len(arguments) - - # Collect all valid GET routes without empty parameters - links = [] - for rule in app.url_map.iter_rules(): - if "GET" in rule.methods and has_no_empty_params(rule): - url = url_for(rule.endpoint, **(rule.defaults or {})) - links.append(url) - return print_links(sorted(links)) # Return the sorted links as HTML - - -@app.route("/") -def root(): - # Redirect the root URL to the site map - return redirect("/site-map", code=302) - - -if __name__ == "__main__": - try: - # Set host and port from environment variables or defaults - host = os.getenv("FLASK_RUN_HOST", "0.0.0.0") - port = os.getenv("FLASK_RUN_PORT", 8503) - app.run(debug=True, host=host, port=port) # Run the Flask application - except Exception as e: - print( - f"Could not bind to host {host}:{port}. Error: {e}" - ) # Error handling for binding issues diff --git a/tests/conftest.py b/tests/conftest.py index 58dfb079..e913735a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,10 +28,10 @@ class Starter(ProcessStarter): ) # command to start server process - args = [sys.executable, "-m", "akkudoktoreosserver.flask_server"] + args = [sys.executable, "-m", "akkudoktoreosserver.fastapi_server"] # startup pattern - pattern = "Debugger PIN:" + pattern = "Application startup complete." # search the first 12 lines for the startup pattern, if not found # a RuntimeError will be raised informing the user max_read_lines = 12 diff --git a/tests/test_class_akku.py b/tests/test_class_akku.py index 8f09a483..dca5e30e 100644 --- a/tests/test_class_akku.py +++ b/tests/test_class_akku.py @@ -1,6 +1,6 @@ import unittest -from akkudoktoreos.class_akku import PVAkku +from akkudoktoreos.class_akku import PVAkku, PVAkkuParameters class TestPVAkku(unittest.TestCase): @@ -14,21 +14,25 @@ def setUp(self): def test_initial_state_of_charge(self): akku = PVAkku( - self.kapazitaet_wh, + PVAkkuParameters( + kapazitaet_wh=self.kapazitaet_wh, + start_soc_prozent=50, + min_soc_prozent=self.min_soc_prozent, + max_soc_prozent=self.max_soc_prozent, + ), hours=1, - start_soc_prozent=50, - min_soc_prozent=self.min_soc_prozent, - max_soc_prozent=self.max_soc_prozent, ) self.assertEqual(akku.ladezustand_in_prozent(), 50.0, "Initial SoC should be 50%") def test_discharge_below_min_soc(self): akku = PVAkku( - self.kapazitaet_wh, + PVAkkuParameters( + kapazitaet_wh=self.kapazitaet_wh, + start_soc_prozent=50, + min_soc_prozent=self.min_soc_prozent, + max_soc_prozent=self.max_soc_prozent, + ), hours=1, - start_soc_prozent=50, - min_soc_prozent=self.min_soc_prozent, - max_soc_prozent=self.max_soc_prozent, ) akku.reset() # Try to discharge more energy than available above min_soc @@ -43,11 +47,13 @@ def test_discharge_below_min_soc(self): def test_charge_above_max_soc(self): akku = PVAkku( - self.kapazitaet_wh, + PVAkkuParameters( + kapazitaet_wh=self.kapazitaet_wh, + start_soc_prozent=50, + min_soc_prozent=self.min_soc_prozent, + max_soc_prozent=self.max_soc_prozent, + ), hours=1, - start_soc_prozent=50, - min_soc_prozent=self.min_soc_prozent, - max_soc_prozent=self.max_soc_prozent, ) akku.reset() # Try to charge more energy than available up to max_soc @@ -62,11 +68,13 @@ def test_charge_above_max_soc(self): def test_charging_at_max_soc(self): akku = PVAkku( - self.kapazitaet_wh, + PVAkkuParameters( + kapazitaet_wh=self.kapazitaet_wh, + start_soc_prozent=80, + min_soc_prozent=self.min_soc_prozent, + max_soc_prozent=self.max_soc_prozent, + ), hours=1, - start_soc_prozent=80, - min_soc_prozent=self.min_soc_prozent, - max_soc_prozent=self.max_soc_prozent, ) akku.reset() # Try to charge when SoC is already at max_soc @@ -80,11 +88,13 @@ def test_charging_at_max_soc(self): def test_discharging_at_min_soc(self): akku = PVAkku( - self.kapazitaet_wh, + PVAkkuParameters( + kapazitaet_wh=self.kapazitaet_wh, + start_soc_prozent=20, + min_soc_prozent=self.min_soc_prozent, + max_soc_prozent=self.max_soc_prozent, + ), hours=1, - start_soc_prozent=20, - min_soc_prozent=self.min_soc_prozent, - max_soc_prozent=self.max_soc_prozent, ) akku.reset() # Try to discharge when SoC is already at min_soc @@ -99,11 +109,13 @@ def test_discharging_at_min_soc(self): def test_soc_limits(self): # Test to ensure that SoC never exceeds max_soc or drops below min_soc akku = PVAkku( - self.kapazitaet_wh, + PVAkkuParameters( + kapazitaet_wh=self.kapazitaet_wh, + start_soc_prozent=50, + min_soc_prozent=self.min_soc_prozent, + max_soc_prozent=self.max_soc_prozent, + ), hours=1, - start_soc_prozent=50, - min_soc_prozent=self.min_soc_prozent, - max_soc_prozent=self.max_soc_prozent, ) akku.reset() akku.soc_wh = ( diff --git a/tests/test_class_ems.py b/tests/test_class_ems.py index e612e043..0ec21ff8 100644 --- a/tests/test_class_ems.py +++ b/tests/test_class_ems.py @@ -1,10 +1,16 @@ import numpy as np import pytest -from akkudoktoreos.class_akku import PVAkku -from akkudoktoreos.class_ems import EnergieManagementSystem -from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet -from akkudoktoreos.class_inverter import Wechselrichter # Example import +from akkudoktoreos.class_akku import EAutoParameters, PVAkku, PVAkkuParameters +from akkudoktoreos.class_ems import ( + EnergieManagementSystem, + EnergieManagementSystemParameters, +) +from akkudoktoreos.class_haushaltsgeraet import ( + Haushaltsgeraet, + HaushaltsgeraetParameters, +) +from akkudoktoreos.class_inverter import Wechselrichter, WechselrichterParameters prediction_hours = 48 optimization_hours = 24 @@ -18,20 +24,28 @@ def create_ems_instance(): Fixture to create an EnergieManagementSystem instance with given test parameters. """ # Initialize the battery and the inverter - akku = PVAkku(kapazitaet_wh=5000, start_soc_prozent=80, hours=48, min_soc_prozent=10) + akku = PVAkku( + PVAkkuParameters(kapazitaet_wh=5000, start_soc_prozent=80, min_soc_prozent=10), + hours=prediction_hours, + ) akku.reset() - wechselrichter = Wechselrichter(10000, akku) + wechselrichter = Wechselrichter(WechselrichterParameters(max_leistung_wh=10000), akku) # Household device (currently not used, set to None) home_appliance = Haushaltsgeraet( + HaushaltsgeraetParameters( + verbrauch_wh=2000, + dauer_h=2, + ), hours=prediction_hours, - verbrauch_wh=2000, - dauer_h=2, ) home_appliance.set_startzeitpunkt(2) # Example initialization of electric car battery - eauto = PVAkku(kapazitaet_wh=26400, start_soc_prozent=10, hours=48, min_soc_prozent=10) + eauto = PVAkku( + EAutoParameters(kapazitaet_wh=26400, start_soc_prozent=10, min_soc_prozent=10), + hours=prediction_hours, + ) # Parameters based on previous example data pv_prognose_wh = [ @@ -136,7 +150,8 @@ def create_ems_instance(): 0.0002780, ] - einspeiseverguetung_euro_pro_wh = [0.00007] * len(strompreis_euro_pro_wh) + einspeiseverguetung_euro_pro_wh = 0.00007 + preis_euro_pro_wh_akku = 0.0001 gesamtlast = [ 676.71, @@ -191,11 +206,14 @@ def create_ems_instance(): # Initialize the energy management system with the respective parameters ems = EnergieManagementSystem( - pv_prognose_wh=pv_prognose_wh, - strompreis_euro_pro_wh=strompreis_euro_pro_wh, - einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, + EnergieManagementSystemParameters( + pv_prognose_wh=pv_prognose_wh, + strompreis_euro_pro_wh=strompreis_euro_pro_wh, + einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, + gesamtlast=gesamtlast, + preis_euro_pro_wh_akku=preis_euro_pro_wh_akku, + ), eauto=eauto, - gesamtlast=gesamtlast, haushaltsgeraet=home_appliance, wechselrichter=wechselrichter, ) diff --git a/tests/test_class_optimize.py b/tests/test_class_optimize.py index b88215d9..28dedb0a 100644 --- a/tests/test_class_optimize.py +++ b/tests/test_class_optimize.py @@ -3,7 +3,7 @@ import pytest -from akkudoktoreos.class_optimize import optimization_problem +from akkudoktoreos.class_optimize import OptimizationParameters, optimization_problem from akkudoktoreos.config import output_dir DIR_TESTDATA = Path(__file__).parent / "testdata" @@ -13,7 +13,7 @@ def test_optimize(fn_in, fn_out): # Load input and output data with open(DIR_TESTDATA / fn_in, "r") as f_in: - input_data = json.load(f_in) + input_data = OptimizationParameters(**json.load(f_in)) with open(DIR_TESTDATA / fn_out, "r") as f_out: expected_output_data = json.load(f_out) @@ -24,7 +24,7 @@ def test_optimize(fn_in, fn_out): start_hour = 10 # Call the optimization function - ergebnis = opt_class.optimierung_ems(parameter=input_data, start_hour=start_hour, ngen=3) + ergebnis = opt_class.optimierung_ems(parameters=input_data, start_hour=start_hour, ngen=3) # Assert that the output contains all expected entries. # This does not assert that the optimization always gives the same result! diff --git a/tests/testdata/optimize_input_1.json b/tests/testdata/optimize_input_1.json index 324ef678..44a6d75e 100644 --- a/tests/testdata/optimize_input_1.json +++ b/tests/testdata/optimize_input_1.json @@ -1,52 +1,58 @@ { - "preis_euro_pro_wh_akku": 0.0001, - "pv_soc": 80, - "pv_akku_cap": 26400, - "year_energy": 4100000, - "einspeiseverguetung_euro_pro_wh": 0.00007, - "max_heizleistung": 1000, - "gesamtlast": [ - 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.00, - 1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, - 1178.71, 1050.98, 988.56, 912.38, 704.61, 516.37, 868.05, 694.34, 608.79, 556.31, - 488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67, - 871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97 - ], - "pv_forecast": [ - 0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69, - 6018.82, 5519.07, 3969.88, 3017.96, 1943.07, 1007.17, 319.67, 7.88, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 5.04, 335.59, 705.32, 1121.12, 1604.79, 2157.38, 1433.25, 5718.49, - 4553.96, 3027.55, 2574.46, 1720.4, 963.4, 383.3, 0, 0, 0 - ], + "ems": { + "preis_euro_pro_wh_akku": 0.0001, + "einspeiseverguetung_euro_pro_wh": 0.00007, + "gesamtlast": [ + 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.00, + 1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, + 1178.71, 1050.98, 988.56, 912.38, 704.61, 516.37, 868.05, 694.34, 608.79, 556.31, + 488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67, + 871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97 + ], + "pv_prognose_wh": [ + 0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69, + 6018.82, 5519.07, 3969.88, 3017.96, 1943.07, 1007.17, 319.67, 7.88, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 5.04, 335.59, 705.32, 1121.12, 1604.79, 2157.38, 1433.25, 5718.49, + 4553.96, 3027.55, 2574.46, 1720.4, 963.4, 383.3, 0, 0, 0 + ], + "strompreis_euro_pro_wh": [ + 0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334, 0.0003290, + 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212, 0.0002093, 0.0001879, + 0.0001838, 0.0002004, 0.0002198, 0.0002270, 0.0002997, 0.0003195, 0.0003081, + 0.0002969, 0.0002921, 0.0002780, 0.0003384, 0.0003318, 0.0003284, 0.0003283, + 0.0003289, 0.0003334, 0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280, + 0.0002212, 0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270, + 0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780 + ] + }, + "pv_akku": { + "kapazitaet_wh": 26400, + "max_ladeleistung_w": 5000, + "start_soc_prozent": 80, + "min_soc_prozent": 15 + }, + "wechselrichter": { + "max_leistung_wh": 10000 + }, + "eauto": { + "kapazitaet_wh": 60000, + "lade_effizienz": 0.95, + "entlade_effizienz": 1.0, + "max_ladeleistung_w": 11040, + "start_soc_prozent": 54, + "min_soc_prozent": 0 + }, "temperature_forecast": [ 18.3, 17.8, 16.9, 16.2, 15.6, 15.1, 14.6, 14.2, 14.3, 14.8, 15.7, 16.7, 17.4, 18.0, 18.6, 19.2, 19.1, 18.7, 18.5, 17.7, 16.2, 14.6, 13.6, 13.0, 12.6, 12.2, 11.7, 11.6, 11.3, 11.0, 10.7, 10.2, 11.4, 14.4, 16.4, 18.3, 19.5, 20.7, 21.9, 22.7, 23.1, 23.1, 22.8, 21.8, 20.2, 19.1, 18.0, 17.4 ], - "strompreis_euro_pro_wh": [ - 0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334, 0.0003290, - 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212, 0.0002093, 0.0001879, - 0.0001838, 0.0002004, 0.0002198, 0.0002270, 0.0002997, 0.0003195, 0.0003081, - 0.0002969, 0.0002921, 0.0002780, 0.0003384, 0.0003318, 0.0003284, 0.0003283, - 0.0003289, 0.0003334, 0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280, - 0.0002212, 0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270, - 0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780 - ], - "eauto_min_soc": 0, - "eauto_cap": 60000, - "eauto_charge_efficiency": 0.95, - "eauto_charge_power": 11040, - "eauto_soc": 54, - "pvpowernow": 211.137503624, "start_solution": [ 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 - ], - "haushaltsgeraet_wh": 937, - "haushaltsgeraet_dauer": 0, - "min_soc_prozent": 15 + ] } \ No newline at end of file From 845868490fdf00b359f07083dce38c040f409564 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Sat, 12 Oct 2024 12:33:44 +0200 Subject: [PATCH 02/21] Move API doc from README to pydantic model classes (swagger) * Add API to sphynx doc (rudimental, due to outdated plugin). * Link to swagger.io with own openapi.yml. * Commit openapi.json and check with pytest for changes so the documentation is always up-to-date. --- .gitignore | 3 + README.md | 226 +-------------------- docs/akkudoktoreos/api.rst | 16 ++ docs/conf.py | 4 + docs/index.rst | 1 + openapi.json | 1 + requirements-dev.txt | 1 + single_test_optimization.py | 8 +- src/akkudoktoreos/class_akku.py | 38 +++- src/akkudoktoreos/class_ems.py | 20 +- src/akkudoktoreos/class_haushaltsgeraet.py | 10 +- src/akkudoktoreos/class_optimize.py | 130 +++++++++++- src/akkudoktoreos/class_pv_forecast.py | 6 + src/akkudoktoreos/visualize.py | 2 +- src/akkudoktoreosserver/fastapi_server.py | 35 +++- tests/generate_openapi.py | 24 +++ tests/test_class_ems.py | 2 +- tests/test_class_optimize.py | 8 +- tests/test_openapi.py | 20 ++ tests/testdata/optimize_result_1.json | 3 +- 20 files changed, 298 insertions(+), 260 deletions(-) create mode 100644 docs/akkudoktoreos/api.rst create mode 100644 openapi.json create mode 100644 tests/generate_openapi.py create mode 100644 tests/test_openapi.py diff --git a/.gitignore b/.gitignore index 2b43d60e..c0501e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -248,3 +248,6 @@ $RECYCLE.BIN/ # Visualization side effects **/visualization_results.pdf + +# Test files +openapi-new.json diff --git a/README.md b/README.md index 1879143f..9b200377 100644 --- a/README.md +++ b/README.md @@ -94,228 +94,6 @@ These classes work together to enable a detailed simulation and optimization of Each class is designed to be easily customized and extended to integrate additional functions or improvements. For example, new methods can be added for more accurate modeling of PV system or battery behavior. Developers are invited to modify and extend the system according to their needs. -# Input for the Flask Server (as of 30.07.2024) +# Server API -Describes the structure and data types of the JSON object sent to the Flask server, with a forecast period of 48 hours. - -## JSON Object Fields - -### `strompreis_euro_pro_wh` -- **Description**: An array of floats representing the electricity price in euros per watt-hour for different time intervals. -- **Type**: Array -- **Element Type**: Float -- **Length**: 48 - -### `gesamtlast` -- **Description**: An array of floats representing the total load (consumption) in watts for different time intervals. -- **Type**: Array -- **Element Type**: Float -- **Length**: 48 - -### `pv_forecast` -- **Description**: An array of floats representing the forecasted photovoltaic output in watts for different time intervals. -- **Type**: Array -- **Element Type**: Float -- **Length**: 48 - -### `temperature_forecast` -- **Description**: An array of floats representing the temperature forecast in degrees Celsius for different time intervals. -- **Type**: Array -- **Element Type**: Float -- **Length**: 48 - -### `pv_soc` -- **Description**: An integer representing the state of charge of the PV battery at the **start** of the current hour (not the current state). -- **Type**: Integer - -### `pv_akku_cap` -- **Description**: An integer representing the capacity of the photovoltaic battery in watt-hours. -- **Type**: Integer - -### `einspeiseverguetung_euro_pro_wh` -- **Description**: A float representing the feed-in compensation in euros per watt-hour. -- **Type**: Float - -### `eauto_min_soc` -- **Description**: An integer representing the minimum state of charge (SOC) of the electric vehicle in percentage. -- **Type**: Integer - -### `eauto_cap` -- **Description**: An integer representing the capacity of the electric vehicle battery in watt-hours. -- **Type**: Integer - -### `eauto_charge_efficiency` -- **Description**: A float representing the charging efficiency of the electric vehicle. -- **Type**: Float - -### `eauto_charge_power` -- **Description**: An integer representing the charging power of the electric vehicle in watts. -- **Type**: Integer - -### `eauto_soc` -- **Description**: An integer representing the current state of charge (SOC) of the electric vehicle in percentage. -- **Type**: Integer - -### `start_solution` -- **Description**: Can be `null` or contain a previous solution (if available). -- **Type**: `null` or object - -### `haushaltsgeraet_wh` -- **Description**: An integer representing the energy consumption of a household device in watt-hours. -- **Type**: Integer - -### `haushaltsgeraet_dauer` -- **Description**: An integer representing the usage duration of a household device in hours. -- **Type**: Integer - - - -# JSON Output Description - -This document describes the structure and data types of the JSON output returned by the Flask server, with a forecast period of 48 hours. - -**Note**: The first value of "Last_Wh_pro_Stunde", "Netzeinspeisung_Wh_pro_Stunde" and "Netzbezug_Wh_pro_Stunde", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged. - -## JSON Output Fields (as of 30.7.2024) - -### discharge_hours_bin -An array that indicates for each hour of the forecast period (in this example, 48 hours) whether energy is discharged from the battery or not. The values are either `0` (no discharge) or `1` (discharge). - -### eauto_obj -This object contains information related to the electric vehicle and its charging and discharging behavior: - -- **charge_array**: Indicates for each hour whether the EV is charging (`0` for no charging, `1` for charging). - - **Type**: Array - - **Element Type**: Integer (0 or 1) - - **Length**: 48 -- **discharge_array**: Indicates for each hour whether the EV is discharging (`0` for no discharging, `1` for discharging). - - **Type**: Array - - **Element Type**: Integer (0 or 1) - - **Length**: 48 -- **entlade_effizienz**: The discharge efficiency as a float. - - **Type**: Float -- **hours**: Amount of hours the simulation is done for. - - **Type**: Integer -- **kapazitaet_wh**: The capacity of the EV’s battery in watt-hours. - - **Type**: Integer -- **lade_effizienz**: The charging efficiency as a float. - - **Type**: Float -- **max_ladeleistung_w**: The maximum charging power of the EV in watts. - - **Type**: Float -- **max_ladeleistung_w**: Max charging power of the EV in Watts. - - **Type**: Integer -- **soc_wh**: The state of charge of the battery in watt-hours at the start of the simulation. - - **Type**: Integer -- **start_soc_prozent**: The state of charge of the battery in percentage at the start of the simulation. - - **Type**: Integer - -### eautocharge_hours_float -An array of binary values (0 or 1) that indicates whether the EV will be charged in a certain hour. -- **Type**: Array -- **Element Type**: Integer (0 or 1) -- **Length**: 48 - -### result -This object contains the results of the simulation and provides insights into various parameters over the entire forecast period: - -- **E-Auto_SoC_pro_Stunde**: The state of charge of the EV for each hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Eigenverbrauch_Wh_pro_Stunde**: The self-consumption of the system in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Einnahmen_Euro_pro_Stunde**: The revenue from grid feed-in or other sources in euros per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Gesamt_Verluste**: The total losses in watt-hours over the entire period. - - **Type**: Float -- **Gesamtbilanz_Euro**: The total balance of revenues minus costs in euros. - - **Type**: Float -- **Gesamteinnahmen_Euro**: The total revenues in euros. - - **Type**: Float -- **Gesamtkosten_Euro**: The total costs in euros. - - **Type**: Float -- **Haushaltsgeraet_wh_pro_stunde**: The energy consumption of a household appliance in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Kosten_Euro_pro_Stunde**: The costs in euros per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Netzbezug_Wh_pro_Stunde**: The grid energy drawn in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Netzeinspeisung_Wh_pro_Stunde**: The energy fed into the grid in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **Verluste_Pro_Stunde**: The losses in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 -- **akku_soc_pro_stunde**: The state of charge of the battery (not the EV) in percentage per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - -### simulation_data -An object containing the simulated data. - - **E-Auto_SoC_pro_Stunde**: An array of floats representing the simulated state of charge of the electric car per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Eigenverbrauch_Wh_pro_Stunde**: An array of floats representing the simulated self-consumption in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Einnahmen_Euro_pro_Stunde**: An array of floats representing the simulated income in euros per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Gesamt_Verluste**: The total simulated losses in watt-hours. - - **Type**: Float - - **Gesamtbilanz_Euro**: The total simulated balance in euros. - - **Type**: Float - - **Gesamteinnahmen_Euro**: The total simulated income in euros. - - **Type**: Float - - **Gesamtkosten_Euro**: The total simulated costs in euros. - - **Type**: Float - - **Haushaltsgeraet_wh_pro_stunde**: An array of floats representing the simulated energy consumption of a household appliance in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Kosten_Euro_pro_Stunde**: An array of floats representing the simulated costs in euros per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Netzbezug_Wh_pro_Stunde**: An array of floats representing the simulated grid consumption in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Netzeinspeisung_Wh_pro_Stunde**: An array of floats representing the simulated grid feed-in in watt-hours per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **Verluste_Pro_Stunde**: An array of floats representing the simulated losses per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - - **akku_soc_pro_stunde**: An array of floats representing the simulated state of charge of the battery in percentage per hour. - - **Type**: Array - - **Element Type**: Float - - **Length**: 35 - -### spuelstart -- **Description**: Can be `null` or contain an object representing the start of washing (if applicable). -- **Type**: null or object - -### start_solution -- **Description**: An array of binary values (0 or 1) representing a possible starting solution for the simulation. -- **Type**: Array -- **Element Type**: Integer (0 or 1) -- **Length**: 48 +See the Swagger documentation for detailed information: [EOS OpenAPI Spec](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json) diff --git a/docs/akkudoktoreos/api.rst b/docs/akkudoktoreos/api.rst new file mode 100644 index 00000000..275b6f3a --- /dev/null +++ b/docs/akkudoktoreos/api.rst @@ -0,0 +1,16 @@ +.. + SPDX-License-Identifier: Apache-2.0 + +.. _akkudoktoreos_api: + +API +### + +For a more detailed documentation see the Swagger interface: `EOS OpenAPI Spec `_ + +.. openapi:: ../../openapi.json + :examples: + +.. + Due to bugs in sphinxcontrib-openapi referenced request/response objects fail to render and anyOf is broken too. + :request: diff --git a/docs/conf.py b/docs/conf.py index 8d650608..d45fe77f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,6 +25,7 @@ "sphinx.ext.autosummary", "sphinx_rtd_theme", "myst_parser", + "sphinxcontrib.openapi", ] templates_path = ["_templates"] @@ -46,3 +47,6 @@ "logo_only": False, "titles_only": True, } + + +openapi_default_renderer = "httpdomain:old" diff --git a/docs/index.rst b/docs/index.rst index 2986047a..4dbd642b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,3 +15,4 @@ Akkudoktor EOS documentation akkudoktoreos/about develop/getting_started develop/CONTRIBUTING + akkudoktoreos/api diff --git a/openapi.json b/openapi.json new file mode 100644 index 00000000..49f5d547 --- /dev/null +++ b/openapi.json @@ -0,0 +1 @@ +{"openapi": "3.1.0", "info": {"title": "Akkudoktor-EOS", "description": "This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.", "version": "0.0.1"}, "paths": {"/strompreis": {"get": {"summary": "Fastapi Strompreis", "operationId": "fastapi_strompreis_strompreis_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"type": "number"}, "type": "array", "title": "Response Fastapi Strompreis Strompreis Get"}}}}}}}, "/gesamtlast": {"post": {"summary": "Fastapi Gesamtlast", "description": "Endpoint to handle total load calculation based on the latest measured data", "operationId": "fastapi_gesamtlast_gesamtlast_post", "parameters": [{"name": "year_energy", "in": "query", "required": true, "schema": {"type": "number", "title": "Year Energy"}}, {"name": "hours", "in": "query", "required": false, "schema": {"type": "integer", "default": 48, "title": "Hours"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "array", "items": {"type": "object"}, "title": "Measured Data"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "number"}, "title": "Response Fastapi Gesamtlast Gesamtlast Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/gesamtlast_simple": {"get": {"summary": "Fastapi Gesamtlast Simple", "operationId": "fastapi_gesamtlast_simple_gesamtlast_simple_get", "parameters": [{"name": "year_energy", "in": "query", "required": true, "schema": {"type": "number", "title": "Year Energy"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "number"}, "title": "Response Fastapi Gesamtlast Simple Gesamtlast Simple Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/pvforecast": {"get": {"summary": "Fastapi Pvprognose", "operationId": "fastapi_pvprognose_pvforecast_get", "parameters": [{"name": "url", "in": "query", "required": true, "schema": {"type": "string", "title": "Url"}}, {"name": "ac_power_measurement", "in": "query", "required": false, "schema": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Ac Power Measurement"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForecastResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/optimize": {"post": {"summary": "Fastapi Optimize", "operationId": "fastapi_optimize_optimize_post", "parameters": [{"name": "start_hour", "in": "query", "required": false, "schema": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "Defaults to current hour of the day.", "title": "Start Hour"}, "description": "Defaults to current hour of the day."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OptimizationParameters"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OptimizeResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/visualization_results.pdf": {"get": {"summary": "Get Pdf", "operationId": "get_pdf_visualization_results_pdf_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"EAutoParameters": {"properties": {"kapazitaet_wh": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Kapazitaet Wh", "description": "An integer representing the capacity of the battery in watt-hours."}, "lade_effizienz": {"type": "number", "maximum": 1.0, "exclusiveMinimum": 0.0, "title": "Lade Effizienz", "description": "A float representing the charging efficiency of the battery.", "default": 0.88}, "entlade_effizienz": {"type": "number", "title": "Entlade Effizienz", "default": 1.0}, "max_ladeleistung_w": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Max Ladeleistung W", "description": "An integer representing the charging power of the battery in watts."}, "start_soc_prozent": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Start Soc Prozent", "description": "An integer representing the current state of charge (SOC) of the battery in percentage.", "default": 0}, "min_soc_prozent": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Min Soc Prozent", "description": "An integer representing the minimum state of charge (SOC) of the battery in percentage.", "default": 0}, "max_soc_prozent": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Max Soc Prozent", "default": 100}}, "type": "object", "required": ["kapazitaet_wh"], "title": "EAutoParameters"}, "EAutoResult": {"properties": {"charge_array": {"items": {"type": "number"}, "type": "array", "title": "Charge Array", "description": "Indicates for each hour whether the EV is charging (`0` for no charging, `1` for charging)."}, "discharge_array": {"items": {"type": "integer"}, "type": "array", "title": "Discharge Array", "description": "Indicates for each hour whether the EV is discharging (`0` for no discharging, `1` for discharging)."}, "entlade_effizienz": {"type": "number", "title": "Entlade Effizienz", "description": "The discharge efficiency as a float."}, "hours": {"type": "integer", "title": "Hours", "default": "Amount of hours the simulation is done for."}, "kapazitaet_wh": {"type": "integer", "title": "Kapazitaet Wh", "default": "The capacity of the EV\u2019s battery in watt-hours."}, "lade_effizienz": {"type": "number", "title": "Lade Effizienz", "default": "The charging efficiency as a float."}, "max_ladeleistung_w": {"type": "integer", "title": "Max Ladeleistung W", "description": "The maximum charging power of the EV in watts."}, "soc_wh": {"type": "number", "title": "Soc Wh", "description": "The state of charge of the battery in watt-hours at the start of the simulation."}, "start_soc_prozent": {"type": "integer", "title": "Start Soc Prozent", "description": "The state of charge of the battery in percentage at the start of the simulation."}}, "type": "object", "required": ["charge_array", "discharge_array", "entlade_effizienz", "max_ladeleistung_w", "soc_wh", "start_soc_prozent"], "title": "EAutoResult", "description": "\"This object contains information related to the electric vehicle and its charging and discharging behavior"}, "EnergieManagementSystemParameters": {"properties": {"pv_prognose_wh": {"items": {"type": "number"}, "type": "array", "title": "Pv Prognose Wh", "description": "An array of floats representing the forecasted photovoltaic output in watts for different time intervals."}, "strompreis_euro_pro_wh": {"items": {"type": "number"}, "type": "array", "title": "Strompreis Euro Pro Wh", "description": "An array of floats representing the electricity price in euros per watt-hour for different time intervals."}, "einspeiseverguetung_euro_pro_wh": {"type": "number", "title": "Einspeiseverguetung Euro Pro Wh", "description": "A float representing the feed-in compensation in euros per watt-hour."}, "preis_euro_pro_wh_akku": {"type": "number", "title": "Preis Euro Pro Wh Akku"}, "gesamtlast": {"items": {"type": "number"}, "type": "array", "title": "Gesamtlast", "description": "An array of floats representing the total load (consumption) in watts for different time intervals."}}, "type": "object", "required": ["pv_prognose_wh", "strompreis_euro_pro_wh", "einspeiseverguetung_euro_pro_wh", "preis_euro_pro_wh_akku", "gesamtlast"], "title": "EnergieManagementSystemParameters"}, "ForecastResponse": {"properties": {"temperature": {"items": {"type": "number"}, "type": "array", "title": "Temperature"}, "pvpower": {"items": {"type": "number"}, "type": "array", "title": "Pvpower"}}, "type": "object", "required": ["temperature", "pvpower"], "title": "ForecastResponse"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "HaushaltsgeraetParameters": {"properties": {"verbrauch_wh": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Verbrauch Wh", "description": "An integer representing the energy consumption of a household device in watt-hours."}, "dauer_h": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Dauer H", "description": "An integer representing the usage duration of a household device in hours."}}, "type": "object", "required": ["verbrauch_wh", "dauer_h"], "title": "HaushaltsgeraetParameters"}, "OptimizationParameters": {"properties": {"ems": {"$ref": "#/components/schemas/EnergieManagementSystemParameters"}, "pv_akku": {"$ref": "#/components/schemas/PVAkkuParameters"}, "wechselrichter": {"$ref": "#/components/schemas/WechselrichterParameters", "default": {"max_leistung_wh": 10000.0}}, "eauto": {"$ref": "#/components/schemas/EAutoParameters"}, "spuelmaschine": {"anyOf": [{"$ref": "#/components/schemas/HaushaltsgeraetParameters"}, {"type": "null"}]}, "temperature_forecast": {"items": {"type": "number"}, "type": "array", "title": "Temperature Forecast", "default": "An array of floats representing the temperature forecast in degrees Celsius for different time intervals."}, "start_solution": {"anyOf": [{"items": {"type": "number"}, "type": "array"}, {"type": "null"}], "title": "Start Solution", "description": "Can be `null` or contain a previous solution (if available)."}}, "type": "object", "required": ["ems", "pv_akku", "eauto"], "title": "OptimizationParameters"}, "OptimizeResponse": {"properties": {"discharge_hours_bin": {"items": {"type": "integer"}, "type": "array", "title": "Discharge Hours Bin", "description": "An array that indicates for each hour of the forecast period whether energy is discharged from the battery or not. The values are either `0` (no discharge) or `1` (discharge)."}, "eautocharge_hours_float": {"items": {"type": "number"}, "type": "array", "title": "Eautocharge Hours Float", "description": "An array of binary values (0 or 1) that indicates whether the EV will be charged in a certain hour."}, "result": {"$ref": "#/components/schemas/SimulationResult"}, "eauto_obj": {"$ref": "#/components/schemas/EAutoResult"}, "start_solution": {"anyOf": [{"items": {"type": "number"}, "type": "array"}, {"type": "null"}], "title": "Start Solution", "description": "An array of binary values (0 or 1) representing a possible starting solution for the simulation."}, "spuelstart": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Spuelstart", "description": "Can be `null` or contain an object representing the start of washing (if applicable)."}}, "type": "object", "required": ["discharge_hours_bin", "eautocharge_hours_float", "result", "eauto_obj"], "title": "OptimizeResponse", "description": "**Note**: The first value of \"Last_Wh_pro_Stunde\", \"Netzeinspeisung_Wh_pro_Stunde\" and \"Netzbezug_Wh_pro_Stunde\", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged."}, "PVAkkuParameters": {"properties": {"kapazitaet_wh": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Kapazitaet Wh", "description": "An integer representing the capacity of the battery in watt-hours."}, "lade_effizienz": {"type": "number", "maximum": 1.0, "exclusiveMinimum": 0.0, "title": "Lade Effizienz", "description": "A float representing the charging efficiency of the battery.", "default": 0.88}, "entlade_effizienz": {"type": "number", "maximum": 1.0, "exclusiveMinimum": 0.0, "title": "Entlade Effizienz", "default": 0.88}, "max_ladeleistung_w": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Max Ladeleistung W", "description": "An integer representing the charging power of the battery in watts.", "default": 5000}, "start_soc_prozent": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Start Soc Prozent", "description": "An integer representing the state of charge of the battery at the **start** of the current hour (not the current state).", "default": 0}, "min_soc_prozent": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Min Soc Prozent", "description": "An integer representing the minimum state of charge (SOC) of the battery in percentage.", "default": 0}, "max_soc_prozent": {"type": "integer", "maximum": 100.0, "minimum": 0.0, "title": "Max Soc Prozent", "default": 100}}, "type": "object", "required": ["kapazitaet_wh"], "title": "PVAkkuParameters"}, "SimulationResult": {"properties": {"Last_Wh_pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Last Wh Pro Stunde", "description": "TBD"}, "EAuto_SoC_pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Eauto Soc Pro Stunde", "description": "The state of charge of the EV for each hour."}, "Einnahmen_Euro_pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Einnahmen Euro Pro Stunde", "description": "The revenue from grid feed-in or other sources in euros per hour."}, "Gesamt_Verluste": {"type": "number", "title": "Gesamt Verluste", "description": "The total losses in watt-hours over the entire period."}, "Gesamtbilanz_Euro": {"type": "number", "title": "Gesamtbilanz Euro", "description": "The total balance of revenues minus costs in euros."}, "Gesamteinnahmen_Euro": {"type": "number", "title": "Gesamteinnahmen Euro", "description": "The total revenues in euros."}, "Gesamtkosten_Euro": {"type": "number", "title": "Gesamtkosten Euro", "description": "The total costs in euros."}, "Haushaltsgeraet_wh_pro_stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Haushaltsgeraet Wh Pro Stunde", "description": "The energy consumption of a household appliance in watt-hours per hour."}, "Kosten_Euro_pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Kosten Euro Pro Stunde", "description": "The costs in euros per hour."}, "Netzbezug_Wh_pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Netzbezug Wh Pro Stunde", "description": "The grid energy drawn in watt-hours per hour."}, "Netzeinspeisung_Wh_pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Netzeinspeisung Wh Pro Stunde", "description": "The energy fed into the grid in watt-hours per hour."}, "Verluste_Pro_Stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Verluste Pro Stunde", "description": "The losses in watt-hours per hour."}, "akku_soc_pro_stunde": {"items": {"anyOf": [{"type": "number"}, {"type": "null"}]}, "type": "array", "title": "Akku Soc Pro Stunde", "description": "The state of charge of the battery (not the EV) in percentage per hour."}}, "type": "object", "required": ["Last_Wh_pro_Stunde", "EAuto_SoC_pro_Stunde", "Einnahmen_Euro_pro_Stunde", "Gesamt_Verluste", "Gesamtbilanz_Euro", "Gesamteinnahmen_Euro", "Gesamtkosten_Euro", "Haushaltsgeraet_wh_pro_stunde", "Kosten_Euro_pro_Stunde", "Netzbezug_Wh_pro_Stunde", "Netzeinspeisung_Wh_pro_Stunde", "Verluste_Pro_Stunde", "akku_soc_pro_stunde"], "title": "SimulationResult", "description": "This object contains the results of the simulation and provides insights into various parameters over the entire forecast period"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}, "WechselrichterParameters": {"properties": {"max_leistung_wh": {"type": "number", "exclusiveMinimum": 0.0, "title": "Max Leistung Wh", "default": 10000}}, "type": "object", "title": "WechselrichterParameters"}}}} \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 52cc46c1..ff90b268 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ myst-parser==4.0.0 sphinx==8.0.2 sphinx_rtd_theme==3.0.1 +sphinxcontrib-openapi==0.8.4 pytest==8.3.3 pytest-cov==5.0.0 pytest-xprocess==1.0.2 diff --git a/single_test_optimization.py b/single_test_optimization.py index 2d8fa897..8ff39cb0 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -3,7 +3,11 @@ import json # Import necessary modules from the project -from akkudoktoreos.class_optimize import OptimizationParameters, optimization_problem +from akkudoktoreos.class_optimize import ( + OptimizationParameters, + OptimizeResponse, + optimization_problem, +) start_hour = 10 @@ -279,3 +283,5 @@ json_data = json.dumps(ergebnis) print(json_data) + +OptimizeResponse(**ergebnis) diff --git a/src/akkudoktoreos/class_akku.py b/src/akkudoktoreos/class_akku.py index 5c7364a3..9c68af10 100644 --- a/src/akkudoktoreos/class_akku.py +++ b/src/akkudoktoreos/class_akku.py @@ -4,22 +4,48 @@ from pydantic import BaseModel, Field +def max_ladeleistung_w_field(default=None): + return Field( + default, + gt=0, + description="An integer representing the charging power of the battery in watts.", + ) + + +def start_soc_prozent_field(description: str): + return Field(0, ge=0, le=100, description=description) + + class BaseAkkuParameters(BaseModel): - kapazitaet_wh: float = Field(gt=0) - lade_effizienz: float = Field(0.88, gt=0, le=1) + kapazitaet_wh: int = Field( + gt=0, description="An integer representing the capacity of the battery in watt-hours." + ) + lade_effizienz: float = Field( + 0.88, gt=0, le=1, description="A float representing the charging efficiency of the battery." + ) entlade_effizienz: float = Field(0.88, gt=0, le=1) - max_ladeleistung_w: Optional[float] = Field(None, gt=0) - start_soc_prozent: float = Field(0, ge=0, le=100) - min_soc_prozent: int = Field(0, ge=0, le=100) + max_ladeleistung_w: Optional[float] = max_ladeleistung_w_field() + start_soc_prozent: int = start_soc_prozent_field( + "An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)." + ) + min_soc_prozent: int = Field( + 0, + ge=0, + le=100, + description="An integer representing the minimum state of charge (SOC) of the battery in percentage.", + ) max_soc_prozent: int = Field(100, ge=0, le=100) class PVAkkuParameters(BaseAkkuParameters): - max_ladeleistung_w: Optional[float] = 5000 + max_ladeleistung_w: Optional[float] = max_ladeleistung_w_field(5000) class EAutoParameters(BaseAkkuParameters): entlade_effizienz: float = 1.0 + start_soc_prozent: int = start_soc_prozent_field( + "An integer representing the current state of charge (SOC) of the battery in percentage." + ) class PVAkku: diff --git a/src/akkudoktoreos/class_ems.py b/src/akkudoktoreos/class_ems.py index d30e4dd4..7304bd8f 100644 --- a/src/akkudoktoreos/class_ems.py +++ b/src/akkudoktoreos/class_ems.py @@ -2,7 +2,7 @@ from typing import Dict, List, Optional, Union import numpy as np -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, Field, model_validator from typing_extensions import Self from akkudoktoreos.class_akku import PVAkku @@ -11,11 +11,19 @@ class EnergieManagementSystemParameters(BaseModel): - pv_prognose_wh: list[float] - strompreis_euro_pro_wh: list[float] - einspeiseverguetung_euro_pro_wh: float + 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: float = Field( + description="A float representing the feed-in compensation in euros per watt-hour." + ) preis_euro_pro_wh_akku: float - gesamtlast: list[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: @@ -142,7 +150,7 @@ def simuliere(self, start_stunde: int) -> dict: "akku_soc_pro_stunde": akku_soc_pro_stunde, "Einnahmen_Euro_pro_Stunde": einnahmen_euro_pro_stunde, "Gesamtbilanz_Euro": gesamtkosten_euro, - "E-Auto_SoC_pro_Stunde": eauto_soc_pro_stunde, + "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, diff --git a/src/akkudoktoreos/class_haushaltsgeraet.py b/src/akkudoktoreos/class_haushaltsgeraet.py index 8d3c3b6b..8d118f82 100644 --- a/src/akkudoktoreos/class_haushaltsgeraet.py +++ b/src/akkudoktoreos/class_haushaltsgeraet.py @@ -3,8 +3,14 @@ class HaushaltsgeraetParameters(BaseModel): - verbrauch_wh: float = Field(gt=0) - dauer_h: int = Field(gt=0) + verbrauch_wh: int = Field( + gt=0, + description="An integer representing the energy consumption of a household device in watt-hours.", + ) + dauer_h: int = Field( + gt=0, + description="An integer representing the usage duration of a household device in hours.", + ) class Haushaltsgeraet: diff --git a/src/akkudoktoreos/class_optimize.py b/src/akkudoktoreos/class_optimize.py index 9c1fd5e7..b50345b5 100644 --- a/src/akkudoktoreos/class_optimize.py +++ b/src/akkudoktoreos/class_optimize.py @@ -3,7 +3,7 @@ import numpy as np from deap import algorithms, base, creator, tools -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, Field, model_validator from typing_extensions import Self from akkudoktoreos.class_akku import EAutoParameters, PVAkku, PVAkkuParameters @@ -26,8 +26,12 @@ class OptimizationParameters(BaseModel): wechselrichter: WechselrichterParameters = WechselrichterParameters() eauto: EAutoParameters spuelmaschine: Optional[HaushaltsgeraetParameters] = None - temperature_forecast: list[float] - start_solution: Optional[list[float]] = None + temperature_forecast: list[float] = Field( + "An array of floats representing the temperature forecast in degrees Celsius for different time intervals." + ) + start_solution: Optional[list[float]] = Field( + None, description="Can be `null` or contain a previous solution (if available)." + ) @model_validator(mode="after") def validate_list_length(self) -> Self: @@ -37,6 +41,122 @@ def validate_list_length(self) -> Self: return self +class EAutoResult(BaseModel): + """ "This object contains information related to the electric vehicle and its charging and discharging behavior""" + + charge_array: list[float] = Field( + description="Indicates for each hour whether the EV is charging (`0` for no charging, `1` for charging)." + ) + discharge_array: list[int] = Field( + description="Indicates for each hour whether the EV is discharging (`0` for no discharging, `1` for discharging)." + ) + entlade_effizienz: float = Field(description="The discharge efficiency as a float.") + hours: int = Field("Amount of hours the simulation is done for.") + kapazitaet_wh: int = Field("The capacity of the EV’s battery in watt-hours.") + lade_effizienz: float = Field("The charging efficiency as a float.") + max_ladeleistung_w: int = Field(description="The maximum charging power of the EV in watts.") + soc_wh: float = Field( + description="The state of charge of the battery in watt-hours at the start of the simulation." + ) + start_soc_prozent: int = Field( + description="The state of charge of the battery in percentage at the start of the simulation." + ) + + +class SimulationResult(BaseModel): + """This object contains the results of the simulation and provides insights into various parameters over the entire forecast period""" + + Last_Wh_pro_Stunde: list[Optional[float]] = Field(description="TBD") + EAuto_SoC_pro_Stunde: list[Optional[float]] = Field( + description="The state of charge of the EV for each hour." + ) + Einnahmen_Euro_pro_Stunde: list[Optional[float]] = Field( + description="The revenue from grid feed-in or other sources in euros per hour." + ) + Gesamt_Verluste: float = Field( + description="The total losses in watt-hours over the entire period." + ) + Gesamtbilanz_Euro: float = Field( + description="The total balance of revenues minus costs in euros." + ) + Gesamteinnahmen_Euro: float = Field(description="The total revenues in euros.") + Gesamtkosten_Euro: float = Field(description="The total costs in euros.") + Haushaltsgeraet_wh_pro_stunde: list[Optional[float]] = Field( + description="The energy consumption of a household appliance in watt-hours per hour." + ) + Kosten_Euro_pro_Stunde: list[Optional[float]] = Field( + description="The costs in euros per hour." + ) + Netzbezug_Wh_pro_Stunde: list[Optional[float]] = Field( + description="The grid energy drawn in watt-hours per hour." + ) + Netzeinspeisung_Wh_pro_Stunde: list[Optional[float]] = Field( + description="The energy fed into the grid in watt-hours per hour." + ) + Verluste_Pro_Stunde: list[Optional[float]] = Field( + description="The losses in watt-hours per hour." + ) + akku_soc_pro_stunde: list[Optional[float]] = Field( + description="The state of charge of the battery (not the EV) in percentage per hour." + ) + + +# class SimulationData(BaseModel): +# """An object containing the simulated data.""" +# +# Last_Wh_pro_Stunde: list[Optional[float]] = Field(description="TBD") +# EAuto_SoC_pro_Stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated state of charge of the electric car per hour.", +# ) +# Einnahmen_Euro_pro_Stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated income in euros per hour." +# ) +# Gesamt_Verluste: float = Field(description="The total simulated losses in watt-hours.") +# Gesamtbilanz_Euro: float = Field(description="The total simulated balance in euros.") +# Gesamteinnahmen_Euro: float = Field(description="The total simulated income in euros.") +# Gesamtkosten_Euro: float = Field(description="The total simulated costs in euros.") +# Haushaltsgeraet_wh_pro_stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated energy consumption of a household appliance in watt-hours per hour." +# ) +# Kosten_Euro_pro_Stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated costs in euros per hour." +# ) +# Netzbezug_Wh_pro_Stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated grid consumption in watt-hours per hour." +# ) +# Netzeinspeisung_Wh_pro_Stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated grid feed-in in watt-hours per hour." +# ) +# Verluste_Pro_Stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated losses per hour." +# ) +# akku_soc_pro_stunde: list[Optional[float]] = Field( +# description="An array of floats representing the simulated state of charge of the battery in percentage per hour." +# ) + + +class OptimizeResponse(BaseModel): + """**Note**: The first value of "Last_Wh_pro_Stunde", "Netzeinspeisung_Wh_pro_Stunde" and "Netzbezug_Wh_pro_Stunde", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged.""" + + discharge_hours_bin: list[int] = Field( + description="An array that indicates for each hour of the forecast period whether energy is discharged from the battery or not. The values are either `0` (no discharge) or `1` (discharge)." + ) + eautocharge_hours_float: list[float] = Field( + description="An array of binary values (0 or 1) that indicates whether the EV will be charged in a certain hour." + ) + result: SimulationResult + eauto_obj: EAutoResult + start_solution: Optional[list[float]] = Field( + None, + description="An array of binary values (0 or 1) representing a possible starting solution for the simulation.", + ) + spuelstart: Optional[int] = Field( + None, + description="Can be `null` or contain an object representing the start of washing (if applicable).", + ) + # simulation_data: Optional[SimulationData] = None + + class optimization_problem: def __init__( self, @@ -330,7 +450,7 @@ def optimierung_ems( "Netzbezug_Wh_pro_Stunde", "Kosten_Euro_pro_Stunde", "Einnahmen_Euro_pro_Stunde", - "E-Auto_SoC_pro_Stunde", + "EAuto_SoC_pro_Stunde", "Verluste_Pro_Stunde", "Haushaltsgeraet_wh_pro_stunde", ] @@ -358,5 +478,5 @@ def optimierung_ems( "eauto_obj": ems.eauto.to_dict(), "start_solution": start_solution, "spuelstart": spuelstart_int, - "simulation_data": o, + # "simulation_data": o, } diff --git a/src/akkudoktoreos/class_pv_forecast.py b/src/akkudoktoreos/class_pv_forecast.py index 7d10e33e..8badaa2a 100644 --- a/src/akkudoktoreos/class_pv_forecast.py +++ b/src/akkudoktoreos/class_pv_forecast.py @@ -8,6 +8,12 @@ import pandas as pd import requests from dateutil import parser +from pydantic import BaseModel + + +class ForecastResponse(BaseModel): + temperature: list[float] + pvpower: list[float] class ForecastData: diff --git a/src/akkudoktoreos/visualize.py b/src/akkudoktoreos/visualize.py index f5a2d88b..2d9a6d0e 100644 --- a/src/akkudoktoreos/visualize.py +++ b/src/akkudoktoreos/visualize.py @@ -152,7 +152,7 @@ def visualisiere_ergebnisse( plt.plot(hours, ergebnisse["akku_soc_pro_stunde"], label="PV Battery (%)", marker="x") plt.plot( hours, - ergebnisse["E-Auto_SoC_pro_Stunde"], + ergebnisse["EAuto_SoC_pro_Stunde"], label="E-Car Battery (%)", marker="x", ) diff --git a/src/akkudoktoreosserver/fastapi_server.py b/src/akkudoktoreosserver/fastapi_server.py index 56677ed5..254a3a07 100755 --- a/src/akkudoktoreosserver/fastapi_server.py +++ b/src/akkudoktoreosserver/fastapi_server.py @@ -17,8 +17,12 @@ from akkudoktoreos.class_load import LoadForecast from akkudoktoreos.class_load_container import Gesamtlast from akkudoktoreos.class_load_corrector import LoadPredictionAdjuster -from akkudoktoreos.class_optimize import OptimizationParameters, optimization_problem -from akkudoktoreos.class_pv_forecast import PVForecast +from akkudoktoreos.class_optimize import ( + OptimizationParameters, + OptimizeResponse, + optimization_problem, +) +from akkudoktoreos.class_pv_forecast import ForecastResponse, PVForecast from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast from akkudoktoreos.config import ( get_start_enddate, @@ -44,7 +48,7 @@ @app.get("/strompreis") -def fastapi_strompreis(): +def fastapi_strompreis() -> list[float]: # Get the current date and the end date based on prediction hours date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) filepath = os.path.join( @@ -60,9 +64,12 @@ def fastapi_strompreis(): return specific_date_prices.tolist() -# Endpoint to handle total load calculation based on the latest measured data @app.post("/gesamtlast") -def fastapi_gesamtlast(year_energy: float, measured_data: list[dict[str, Any]], hours: int = 48): +def fastapi_gesamtlast( + year_energy: float, measured_data: list[dict[str, Any]], hours: int = 48 +) -> list[float]: + """Endpoint to handle total load calculation based on the latest measured data""" + prediction_hours = hours # Measured data in JSON format @@ -116,7 +123,7 @@ def fastapi_gesamtlast(year_energy: float, measured_data: list[dict[str, Any]], @app.get("/gesamtlast_simple") -def fastapi_gesamtlast_simple(year_energy: float): +def fastapi_gesamtlast_simple(year_energy: float) -> list[float]: date_now, date = get_start_enddate( prediction_hours, startdate=datetime.now().date() ) # Get the current date and prediction end date @@ -153,7 +160,7 @@ def fastapi_gesamtlast_simple(year_energy: float): @app.get("/pvforecast") -def fastapi_pvprognose(url: str, ac_power_measurement: Optional[float] = None): +def fastapi_pvprognose(url: str, ac_power_measurement: Optional[float] = None) -> ForecastResponse: date_now, date = get_start_enddate(prediction_hours, startdate=datetime.now().date()) ############### @@ -182,8 +189,14 @@ def fastapi_pvprognose(url: str, ac_power_measurement: Optional[float] = None): @app.post("/optimize") def fastapi_optimize( - parameters: OptimizationParameters, start_hour: Annotated[int, Query()] = datetime.now().hour -): + parameters: OptimizationParameters, + start_hour: Annotated[ + Optional[int], Query(description="Defaults to current hour of the day.") + ] = None, +) -> OptimizeResponse: + if start_hour is None: + start_hour = datetime.now().hour + # Perform optimization simulation result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour) print(result) @@ -197,12 +210,12 @@ def get_pdf(): return FileResponse(os.path.join(output_dir, "visualization_results.pdf")) -@app.get("/site-map") +@app.get("/site-map", include_in_schema=False) def site_map(): return RedirectResponse(url="/docs") -@app.get("/") +@app.get("/", include_in_schema=False) def root(): # Redirect the root URL to the site map return RedirectResponse(url="/docs") diff --git a/tests/generate_openapi.py b/tests/generate_openapi.py new file mode 100644 index 00000000..7ac4555e --- /dev/null +++ b/tests/generate_openapi.py @@ -0,0 +1,24 @@ +import json +from pathlib import Path + +from fastapi.openapi.utils import get_openapi + +from akkudoktoreosserver.fastapi_server import app + + +def generate_openapi(filename: str | Path = "openapi.json"): + with open(filename, "w") as f: + json.dump( + get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + description=app.description, + routes=app.routes, + ), + f, + ) + + +if __name__ == "__main__": + generate_openapi() diff --git a/tests/test_class_ems.py b/tests/test_class_ems.py index 0ec21ff8..0fbb70b4 100644 --- a/tests/test_class_ems.py +++ b/tests/test_class_ems.py @@ -253,7 +253,7 @@ def test_simulation(create_ems_instance): "akku_soc_pro_stunde", "Einnahmen_Euro_pro_Stunde", "Gesamtbilanz_Euro", - "E-Auto_SoC_pro_Stunde", + "EAuto_SoC_pro_Stunde", "Gesamteinnahmen_Euro", "Gesamtkosten_Euro", "Verluste_Pro_Stunde", diff --git a/tests/test_class_optimize.py b/tests/test_class_optimize.py index 28dedb0a..974d9f50 100644 --- a/tests/test_class_optimize.py +++ b/tests/test_class_optimize.py @@ -3,7 +3,11 @@ import pytest -from akkudoktoreos.class_optimize import OptimizationParameters, optimization_problem +from akkudoktoreos.class_optimize import ( + OptimizationParameters, + OptimizeResponse, + optimization_problem, +) from akkudoktoreos.config import output_dir DIR_TESTDATA = Path(__file__).parent / "testdata" @@ -34,3 +38,5 @@ def test_optimize(fn_in, fn_out): # The function creates a visualization result PDF as a side-effect. fp_viz = Path(output_dir) / "visualization_results.pdf" assert fp_viz.exists() + + OptimizeResponse(**ergebnis) diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 00000000..b88d39a0 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,20 @@ +import json +from pathlib import Path + +from generate_openapi import generate_openapi + +DIR_PROJECT_ROOT = Path(__file__).parent.parent +DIR_TESTDATA = Path(__file__).parent / "testdata" + + +def test_openapi_spec_current(): + """Verify the openapi spec hasn´t changed.""" + + old_spec_path = DIR_PROJECT_ROOT / "openapi.json" + new_spec_path = DIR_TESTDATA / "openapi-new.json" + generate_openapi(new_spec_path) + with open(new_spec_path) as f_new: + new_spec = json.load(f_new) + with open(old_spec_path) as f_old: + old_spec = json.load(f_old) + assert new_spec == old_spec diff --git a/tests/testdata/optimize_result_1.json b/tests/testdata/optimize_result_1.json index 14b188c9..9093fc8d 100644 --- a/tests/testdata/optimize_result_1.json +++ b/tests/testdata/optimize_result_1.json @@ -40,7 +40,6 @@ "start_solution": [ 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 ], - "spuelstart": null, - "simulation_data": null + "spuelstart": null } \ No newline at end of file From 2277d1f1534f7db0ef98c38b71780601173d07e3 Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Thu, 10 Oct 2024 19:01:56 +0200 Subject: [PATCH 03/21] Add package API documentation generation Add generation of the API documentation for akkudoktoreos and akkudoktoreosserver packages. The API documentation is generated by the Sphinx autosummary extension. Signed-off-by: Bobby Noelte --- .gitignore | 3 ++ Makefile | 20 +++++--- docs/_templates/autosummary/class.rst | 33 +++++++++++++ docs/_templates/autosummary/module.rst | 66 ++++++++++++++++++++++++++ docs/akkudoktoreos/api.rst | 19 ++++---- docs/akkudoktoreos/serverapi.rst | 16 +++++++ docs/conf.py | 20 ++++++-- docs/index.rst | 1 + 8 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 docs/_templates/autosummary/class.rst create mode 100644 docs/_templates/autosummary/module.rst create mode 100644 docs/akkudoktoreos/serverapi.rst diff --git a/.gitignore b/.gitignore index c0501e4d..a0aedf87 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ coverage.xml .pytest_cache/ cover/ +# Documentation generation +_autosummary/ + # Translations *.mo *.pot diff --git a/Makefile b/Makefile index 283483ab..8531911f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Define the targets -.PHONY: help venv pip install dist test docker-run docker-build docs clean format run run-dev +.PHONY: help venv pip install dist test docker-run docker-build docs read-docs clean format run run-dev # Default target all: help @@ -15,8 +15,9 @@ help: @echo " docker-run - Run entire setup on docker" @echo " docker-build - Rebuild docker image" @echo " docs - Generate HTML documentation (in build/docs/html/)." - @echo " run - Run FastAPI server in the virtual environment (needs install before)." - @echo " run-dev - Run FastAPI development server in the virtual environment (automatically reloads)." + @echo " read-docs - Read HTML documentation in your browser." + @echo " read-docs - Read HTML documentation in your browser." + @echo " run - Run flask_server in the virtual environment (needs install before)." @echo " dist - Create distribution (in dist/)." @echo " clean - Remove generated documentation, distribution and virtual environment." @@ -53,11 +54,18 @@ docs: pip-dev .venv/bin/sphinx-build -M html docs build/docs @echo "Documentation generated to build/docs/html/." +# Target to read the HTML documentation +read-docs: docs + @echo "Read the documentation in your browser" + .venv/bin/python -m webbrowser build/docs/html/index.html + # Clean target to remove generated documentation, distribution and virtual environment clean: - @echo "Cleaning virtual env, distribution and documentation directories" - rm -rf dist - rm -rf .venv + @echo "Cleaning virtual env, distribution and build directories" + rm -rf dist build .venv + @echo "Searching and deleting all '_autosum' directories in docs..." + @find docs -type d -name '_autosummary' -exec rm -rf {} +; + @echo "Deletion complete." run: @echo "Starting FastAPI server, please wait..." diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 100644 index 00000000..3cddd59e --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1,33 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :undoc-members: + :show-inheritance: + :inherited-members: + + {% block methods %} + .. automethod:: __init__ + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst new file mode 100644 index 00000000..be3cf987 --- /dev/null +++ b/docs/_templates/autosummary/module.rst @@ -0,0 +1,66 @@ +{{ fullname | escape | underline}} + +.. automodule:: {{ fullname }} + + {% block attributes %} + {% if attributes %} + .. rubric:: Module Attributes + + .. autosummary:: + :toctree: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block functions %} + {% if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block classes %} + {% if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + :template: autosummary/class.rst + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block exceptions %} + {% if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + +{% block modules %} +{% if modules %} +.. rubric:: Modules + +.. autosummary:: + :toctree: + :template: autosummary/module.rst + :recursive: +{% for item in modules %} + {{ item }} +{%- endfor %} +{% endif %} +{% endblock %} diff --git a/docs/akkudoktoreos/api.rst b/docs/akkudoktoreos/api.rst index 275b6f3a..b1da8516 100644 --- a/docs/akkudoktoreos/api.rst +++ b/docs/akkudoktoreos/api.rst @@ -1,16 +1,17 @@ .. +<<<<<<< HEAD SPDX-License-Identifier: Apache-2.0 .. _akkudoktoreos_api: -API -### +======= +Akkudoktor EOS API +================== -For a more detailed documentation see the Swagger interface: `EOS OpenAPI Spec `_ +.. autosummary:: + :toctree: _autosummary + :template: autosummary/module.rst + :recursive: -.. openapi:: ../../openapi.json - :examples: - -.. - Due to bugs in sphinxcontrib-openapi referenced request/response objects fail to render and anyOf is broken too. - :request: + akkudoktoreos + akkudoktoreosserver diff --git a/docs/akkudoktoreos/serverapi.rst b/docs/akkudoktoreos/serverapi.rst new file mode 100644 index 00000000..a5d27d56 --- /dev/null +++ b/docs/akkudoktoreos/serverapi.rst @@ -0,0 +1,16 @@ +.. + SPDX-License-Identifier: Apache-2.0 + +.. _akkudoktoreos_server_api: + +Akkudoktor EOS Server API +========================= + +For a more detailed documentation see the Swagger interface: `EOS OpenAPI Spec `_ + +.. openapi:: ../../openapi.json + :examples: + +.. + Due to bugs in sphinxcontrib-openapi referenced request/response objects fail to render and anyOf is broken too. + :request: diff --git a/docs/conf.py b/docs/conf.py index d45fe77f..0cb8f0a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,9 +6,6 @@ import sys from pathlib import Path -# Make source file directories available to sphinx -sys.path.insert(0, str(Path("..", "src").resolve())) - # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -48,5 +45,22 @@ "titles_only": True, } +# -- Options for autodoc ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html + +# Make source file directories available to sphinx +sys.path.insert(0, str(Path("..", "src").resolve())) + +autodoc_default_options = { + "members": "var1, var2", + "member-order": "bysource", + "special-members": "__init__", + "undoc-members": True, + "exclude-members": "__weakref__", +} + +# -- Options for autosummary ------------------------------------------------- +autosummary_generate = True +# -- Options for openapi ----------------------------------------------------- openapi_default_renderer = "httpdomain:old" diff --git a/docs/index.rst b/docs/index.rst index 4dbd642b..b7a186a8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,4 +15,5 @@ Akkudoktor EOS documentation akkudoktoreos/about develop/getting_started develop/CONTRIBUTING + akkudoktoreos/serverapi akkudoktoreos/api From a1cef1e965bc01e33325a5301baf86c39abd8d84 Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Fri, 11 Oct 2024 10:54:15 +0200 Subject: [PATCH 04/21] Enable Google style source commenting and documentation generation. Enable automatic documentation generation from Google style docstrings in the source. Signed-off-by: Bobby Noelte --- docs/conf.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0cb8f0a5..bf1e5025 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,8 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html +"""Configuration file for the Sphinx documentation builder. + +For the full list of built-in configuration values, see the documentation: +https://www.sphinx-doc.org/en/master/usage/configuration.html +""" import sys from pathlib import Path @@ -20,6 +21,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.autosummary", + "sphinx.ext.napoleon", "sphinx_rtd_theme", "myst_parser", "sphinxcontrib.openapi", @@ -64,3 +66,19 @@ # -- Options for openapi ----------------------------------------------------- openapi_default_renderer = "httpdomain:old" + +# -- Options for napoleon ------------------------------------------------- +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_include_init_with_doc = False +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_preprocess_types = False +napoleon_type_aliases = None +napoleon_attr_annotations = True From 882aae6e9b3986f4e6fb9caf068e9b2eb69e103c Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Fri, 11 Oct 2024 10:55:47 +0200 Subject: [PATCH 05/21] Check Google style source commenting. Check Google style commenting by the appropriate ruff rules. Commenting is _NOT_ enforced. Missing docstrings are ignored. Minor commenting quirks of the code base are adapted. Signed-off-by: Bobby Noelte --- docs/akkudoktoreos/api.rst | 2 -- pyproject.toml | 21 +++++++++++++++++++- src/akkudoktoreos/class_akku.py | 4 ++-- src/akkudoktoreos/class_haushaltsgeraet.py | 20 +++++++------------ src/akkudoktoreos/class_load.py | 9 +++------ src/akkudoktoreos/class_load_container.py | 6 ++---- src/akkudoktoreos/class_optimize.py | 23 +++++++++------------- src/akkudoktoreos/heatpump.py | 6 ++++-- tests/test_class_ems.py | 8 ++------ tests/test_heatpump.py | 8 ++++---- tests/test_server.py | 4 +--- 11 files changed, 54 insertions(+), 57 deletions(-) diff --git a/docs/akkudoktoreos/api.rst b/docs/akkudoktoreos/api.rst index b1da8516..dd7933f7 100644 --- a/docs/akkudoktoreos/api.rst +++ b/docs/akkudoktoreos/api.rst @@ -1,10 +1,8 @@ .. -<<<<<<< HEAD SPDX-License-Identifier: Apache-2.0 .. _akkudoktoreos_api: -======= Akkudoktor EOS API ================== diff --git a/pyproject.toml b/pyproject.toml index 9d1f7647..3f66bfda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +40,29 @@ profile = "black" line-length = 100 [tool.ruff.lint] +select = [ + "F", # Enable all `Pyflakes` rules. + "D", # Enable all `pydocstyle` rules, limiting to those that adhere to the + # Google convention via `convention = "google"`, below. +] ignore = [ - "F841", # don't complain about unused variables + # On top of `Pyflakes (F)` to prevent errors for existing sources. Should be removed!!! + "F841", # unused-variable: Local variable {name} is assigned to but never used + # On top of `pydocstyle (D)` to prevent errors for existing sources. Should be removed!!! + "D100", # undocumented-public-module: Missing docstring in public module + "D101", # undocumented-public-class: Missing docstring in public class + "D102", # undocumented-public-method: Missing docstring in public method + "D103", # undocumented-public-function: Missing docstring in public function + "D104", # undocumented-public-package: Missing docstring in public package + "D105", # undocumented-magic-method: Missing docstring in magic method + "D106", # undocumented-public-nested-class: Missing docstring in public nested class + "D107", # undocumented-public-init: Missing docstring in __init__ + "D417", # undocumented-param: Missing argument description in the docstring for {definition}: {name} ] +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.pytest.ini_options] minversion = "8.3.3" addopts = "--cov=src" diff --git a/src/akkudoktoreos/class_akku.py b/src/akkudoktoreos/class_akku.py index 9c68af10..d4eb0576 100644 --- a/src/akkudoktoreos/class_akku.py +++ b/src/akkudoktoreos/class_akku.py @@ -191,8 +191,8 @@ def energie_laden(self, wh, hour): return geladene_menge, verluste_wh def aktueller_energieinhalt(self): - """ - This method returns the current remaining energy considering efficiency. + """This method returns the current remaining energy considering efficiency. + It accounts for both charging and discharging efficiency. """ # Calculate remaining energy considering discharge efficiency diff --git a/src/akkudoktoreos/class_haushaltsgeraet.py b/src/akkudoktoreos/class_haushaltsgeraet.py index 8d118f82..e52b7a8b 100644 --- a/src/akkudoktoreos/class_haushaltsgeraet.py +++ b/src/akkudoktoreos/class_haushaltsgeraet.py @@ -23,8 +23,8 @@ def __init__(self, parameters: HaushaltsgeraetParameters, hours=24): self.lastkurve = np.zeros(self.hours) # Initialize the load curve with zeros def set_startzeitpunkt(self, start_hour, global_start_hour=0): - """ - Sets the start time of the device and generates the corresponding load curve. + """Sets the start time of the device and generates the corresponding load curve. + :param start_hour: The hour at which the device should start. """ self.reset() @@ -41,20 +41,16 @@ def set_startzeitpunkt(self, start_hour, global_start_hour=0): self.lastkurve[start_hour : start_hour + self.dauer_h] = leistung_pro_stunde def reset(self): - """ - Resets the load curve. - """ + """Resets the load curve.""" self.lastkurve = np.zeros(self.hours) def get_lastkurve(self): - """ - Returns the current load curve. - """ + """Returns the current load curve.""" return self.lastkurve def get_last_fuer_stunde(self, hour): - """ - Returns the load for a specific hour. + """Returns the load for a specific hour. + :param hour: The hour for which the load is queried. :return: The load in watts for the specified hour. """ @@ -64,7 +60,5 @@ def get_last_fuer_stunde(self, hour): return self.lastkurve[hour] def spaetestmoeglicher_startzeitpunkt(self): - """ - Returns the latest possible start time at which the device can still run completely. - """ + """Returns the latest possible start time at which the device can still run completely.""" return self.hours - self.dauer_h diff --git a/src/akkudoktoreos/class_load.py b/src/akkudoktoreos/class_load.py index 7e3bffc1..5bf2503d 100644 --- a/src/akkudoktoreos/class_load.py +++ b/src/akkudoktoreos/class_load.py @@ -14,8 +14,7 @@ def __init__(self, filepath=None, year_energy=None): self.load_data() def get_daily_stats(self, date_str): - """ - Returns the 24-hour profile with mean and standard deviation for a given date. + """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 @@ -31,8 +30,7 @@ def get_daily_stats(self, date_str): 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. + """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) @@ -50,8 +48,7 @@ def get_hourly_stats(self, date_str, 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. + """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" diff --git a/src/akkudoktoreos/class_load_container.py b/src/akkudoktoreos/class_load_container.py index 13eccbc0..d19984c6 100644 --- a/src/akkudoktoreos/class_load_container.py +++ b/src/akkudoktoreos/class_load_container.py @@ -7,8 +7,7 @@ def __init__(self, prediction_hours=24): self.prediction_hours = prediction_hours def hinzufuegen(self, name, last_array): - """ - Adds an array of loads for a specific source. + """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 @@ -18,8 +17,7 @@ def hinzufuegen(self, name, 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. + """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 """ diff --git a/src/akkudoktoreos/class_optimize.py b/src/akkudoktoreos/class_optimize.py index b50345b5..baff793c 100644 --- a/src/akkudoktoreos/class_optimize.py +++ b/src/akkudoktoreos/class_optimize.py @@ -182,8 +182,9 @@ def __init__( def split_individual( self, individual: list[float] ) -> Tuple[list[int], list[float], Optional[int]]: - """ - Split the individual solution into its components: + """Split the individual solution into its components. + + Components: 1. Discharge hours (binary), 2. Electric vehicle charge hours (float), 3. Dishwasher start time (integer if applicable). @@ -198,9 +199,7 @@ def split_individual( return discharge_hours_bin, eautocharge_hours_float, spuelstart_int def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> None: - """ - Set up the DEAP environment with fitness and individual creation rules. - """ + """Set up the DEAP environment with fitness and individual creation rules.""" self.opti_param = opti_param # Remove existing FitnessMin and Individual classes from creator if present @@ -246,9 +245,9 @@ def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> def evaluate_inner( self, individual: list[float], ems: EnergieManagementSystem, start_hour: int ) -> dict[str, Any]: - """ - Internal evaluation function that simulates the energy management system (EMS) - using the provided individual solution. + """Simulates the energy management system (EMS) using the provided individual solution. + + This is an internal function. """ ems.reset() discharge_hours_bin, eautocharge_hours_float, spuelstart_int = self.split_individual( @@ -272,9 +271,7 @@ def evaluate( start_hour: int, worst_case: bool, ) -> Tuple[float]: - """ - Evaluate the fitness of an individual solution based on the simulation results. - """ + """Evaluate the fitness of an individual solution based on the simulation results.""" try: o = self.evaluate_inner(individual, ems, start_hour) except Exception as e: @@ -378,9 +375,7 @@ def optimierung_ems( *, ngen: int = 400, ) -> dict[str, Any]: - """ - Perform EMS (Energy Management System) optimization and visualize results. - """ + """Perform EMS (Energy Management System) optimization and visualize results.""" # Initialize PV and EV batteries akku = PVAkku( parameters.pv_akku, diff --git a/src/akkudoktoreos/heatpump.py b/src/akkudoktoreos/heatpump.py index 694305ea..bc506e55 100644 --- a/src/akkudoktoreos/heatpump.py +++ b/src/akkudoktoreos/heatpump.py @@ -35,8 +35,9 @@ def __check_outside_temperature_range__(self, temp_celsius: float) -> bool: return temp_celsius > -100 and temp_celsius < 100 def calculate_cop(self, outside_temperature_celsius: float) -> float: - """Calculate the coefficient of performance (COP) based on outside temperature. Supported - temperate range -100 degree Celsius to 100 degree Celsius. + """Calculate the coefficient of performance (COP) based on outside temperature. + + Supported temperate range -100 degree Celsius to 100 degree Celsius. Args: outside_temperature_celsius: Outside temperature in degree Celsius @@ -59,6 +60,7 @@ def calculate_cop(self, outside_temperature_celsius: float) -> float: def calculate_heating_output(self, outside_temperature_celsius: float) -> float: """Calculate the heating output in Watts based on outside temperature in degree Celsius. + Temperature range must be between -100 and 100 degree Celsius. Args: diff --git a/tests/test_class_ems.py b/tests/test_class_ems.py index 0fbb70b4..63eb4ee7 100644 --- a/tests/test_class_ems.py +++ b/tests/test_class_ems.py @@ -20,9 +20,7 @@ # Example initialization of necessary components @pytest.fixture def create_ems_instance(): - """ - Fixture to create an EnergieManagementSystem instance with given test parameters. - """ + """Fixture to create an EnergieManagementSystem instance with given test parameters.""" # Initialize the battery and the inverter akku = PVAkku( PVAkkuParameters(kapazitaet_wh=5000, start_soc_prozent=80, min_soc_prozent=10), @@ -221,9 +219,7 @@ def create_ems_instance(): def test_simulation(create_ems_instance): - """ - Test the EnergieManagementSystem simulation method. - """ + """Test the EnergieManagementSystem simulation method.""" ems = create_ems_instance # Simulate starting from hour 1 (this value can be adjusted) diff --git a/tests/test_heatpump.py b/tests/test_heatpump.py index e32bfd55..b3385d54 100644 --- a/tests/test_heatpump.py +++ b/tests/test_heatpump.py @@ -5,13 +5,13 @@ @pytest.fixture(scope="function") def hp_5kw_24h() -> Heatpump: - """Heatpump with 5 kw heating power and 24 h prediction""" + """Heatpump with 5 kw heating power and 24 h prediction.""" return Heatpump(5000, 24) class TestHeatpump: def test_cop(self, hp_5kw_24h: Heatpump): - """Testing calculate COP for variouse outside temperatures""" + """Testing calculate COP for variouse outside temperatures.""" assert hp_5kw_24h.calculate_cop(-10) == 2.0 assert hp_5kw_24h.calculate_cop(0) == 3.0 assert hp_5kw_24h.calculate_cop(10) == 4.0 @@ -24,13 +24,13 @@ def test_cop(self, hp_5kw_24h: Heatpump): hp_5kw_24h.calculate_cop(out_temp_max) def test_heating_output(self, hp_5kw_24h: Heatpump): - """Testing calculate of heating output""" + """Testing calculate of heating output.""" assert hp_5kw_24h.calculate_heating_output(-10.0) == 5000 assert hp_5kw_24h.calculate_heating_output(0.0) == 5000 assert hp_5kw_24h.calculate_heating_output(10.0) == pytest.approx(4939.583) def test_heating_power(self, hp_5kw_24h: Heatpump): - """Testing calculation of heating power""" + """Testing calculation of heating power.""" assert hp_5kw_24h.calculate_heat_power(-10.0) == 2104 assert hp_5kw_24h.calculate_heat_power(0.0) == 1164 assert hp_5kw_24h.calculate_heat_power(10.0) == 548 diff --git a/tests/test_server.py b/tests/test_server.py index 4226f021..fbf027a7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,9 +4,7 @@ def test_server(server): - """ - Test the server - """ + """Test the server.""" result = requests.get(f"{server}/gesamtlast_simple?year_energy=2000&") assert result.status_code == 200 assert len(result.json()) == prediction_hours From 906c652ccd5d404e8705424250674409637e4f94 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 16:59:21 +0200 Subject: [PATCH 06/21] Add settings and extension recommendations --- .gitignore | 4 +--- .vscode/extensions.json | 14 ++++++++++++++ .vscode/settings.shared.json | 10 ++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.shared.json diff --git a/.gitignore b/.gitignore index a0aedf87..a8156b19 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,7 @@ output/ # Default ignore folders and files for VS Code, Python .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json +!.vscode/*.shared.json !.vscode/extensions.json !.vscode/*.code-snippets diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..acd42ee4 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + "recommendations": [ + //python + "ms-python.python", + "ms-python.debugpy", + "charliermarsh.ruff", + "ms-python.mypy-type-checker", + + // misc + "swellaby.workspace-config-plus", // allows user and shared settings + "christian-kohler.path-intellisense", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/settings.shared.json b/.vscode/settings.shared.json new file mode 100644 index 00000000..8a09bc9d --- /dev/null +++ b/.vscode/settings.shared.json @@ -0,0 +1,10 @@ +{ + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFoldingRangeProvider": "charliermarsh.ruff", + "editor.formatOnSave": true, + "python.analysis.autoImportCompletions": true, + "mypy-type-checker.importStrategy": "fromEnvironment" +} From f6e8adabcc3381c739934d111dafd51a2ec76671 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 16:59:21 +0200 Subject: [PATCH 07/21] Add pyright section for pylance extension --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3f66bfda..08ecd4bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,11 @@ include = ["akkudoktoreos", "akkudoktoreosserver", ] [tool.setuptools.package-data] akkudoktoreosserver = ["data/*.npz", ] +[tool.pyright] +# used in Pylance extension for language server +# type check is done by mypy, disable to avoid unwanted errors +typeCheckingMode = "off" + [tool.isort] profile = "black" From 6d09e44be22ffd70db2257e9f7e82a8d012d4cf9 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 16:59:21 +0200 Subject: [PATCH 08/21] Enable pytest and debugging - replace outdated modules with src - removed cov arg from pyproject toml - add settings --- .github/workflows/pytest.yml | 4 ++-- .vscode/settings.shared.json | 5 ++++- CONTRIBUTING.md | 4 ++-- Makefile | 2 +- pyproject.toml | 1 - 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 183a82af..f9292f39 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - name: Install dependencies run: | @@ -26,4 +26,4 @@ jobs: - name: Run Pytest run: | pip install -e . - python -m pytest -vs --cov modules --cov-report term-missing tests/ + python -m pytest -vs --cov src --cov-report term-missing tests/ diff --git a/.vscode/settings.shared.json b/.vscode/settings.shared.json index 8a09bc9d..1cc686fc 100644 --- a/.vscode/settings.shared.json +++ b/.vscode/settings.shared.json @@ -6,5 +6,8 @@ "editor.defaultFoldingRangeProvider": "charliermarsh.ruff", "editor.formatOnSave": true, "python.analysis.autoImportCompletions": true, - "mypy-type-checker.importStrategy": "fromEnvironment" + "mypy-type-checker.importStrategy": "fromEnvironment", + "python.testing.pytestArgs": [], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 229d33be..c6b64447 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ The `EOS` project is in early development, therefore we encourage contribution i ## Bug Reports -Please report flaws or vulnerabilities in the [GitHub Issue Tracker]((https://github.com/Akkudoktor-EOS/EOS/issues)) using the corresponding issue template. +Please report flaws or vulnerabilities in the [GitHub Issue Tracker](https://github.com/Akkudoktor-EOS/EOS/issues) using the corresponding issue template. ## Ideas & Features @@ -44,5 +44,5 @@ pre-commit run --all-files Use `pytest` to run tests locally: ```bash -python -m pytest -vs --cov modules --cov-report term-missing tests/ +python -m pytest -vs --cov src --cov-report term-missing tests/ ``` diff --git a/Makefile b/Makefile index 8531911f..0d0d565a 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ test-setup: pip-dev # Target to run tests. test: @echo "Running tests..." - .venv/bin/pytest + .venv/bin/pytest -vs --cov src --cov-report term-missing # Target to format code. format: diff --git a/pyproject.toml b/pyproject.toml index 08ecd4bf..a9d5f645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,5 @@ convention = "google" [tool.pytest.ini_options] minversion = "8.3.3" -addopts = "--cov=src" pythonpath = [ "src", ] testpaths = [ "tests", ] From 53905cb552d94bc1105fbbcac193cc7ad9634578 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 22:31:29 +0200 Subject: [PATCH 09/21] Prettier files --- .pre-commit-config.yaml | 42 +++---- README.md | 250 +++++++++++++++++++++++++++++++++++++++- docker-compose.yaml | 14 +-- 3 files changed, 277 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6457f35d..a2ed1c20 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,24 @@ # Exclude some file types from automatic code style exclude: \.(json|csv)$ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: check-merge-conflict - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - name: isort -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 - hooks: - # Run the linter and fix simple issues automatically - - id: ruff - args: [ --fix ] - # Run the formatter. - - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + name: isort + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.8 + hooks: + # Run the linter and fix simple issues automatically + - id: ruff + args: [--fix] + # Run the formatter. + - id: ruff-format diff --git a/README.md b/README.md index 9b200377..b4b7453d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ To always use the Python version from the virtual environment, you should activa ```bash source .venv/bin/activate ``` + (for Bash users, the default under Linux) or ```zsh @@ -93,7 +94,254 @@ These classes work together to enable a detailed simulation and optimization of Each class is designed to be easily customized and extended to integrate additional functions or improvements. For example, new methods can be added for more accurate modeling of PV system or battery behavior. Developers are invited to modify and extend the system according to their needs. - # Server API See the Swagger documentation for detailed information: [EOS OpenAPI Spec](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json) + +# Input for the FastAPI Server (as of 30.07.2024) + +Describes the structure and data types of the JSON object sent to the Flask server, with a forecast period of 48 hours. + +## JSON Object Fields + +### `strompreis_euro_pro_wh` + +- **Description**: An array of floats representing the electricity price in euros per watt-hour for different time intervals. +- **Type**: Array +- **Element Type**: Float +- **Length**: 48 + +### `gesamtlast` + +- **Description**: An array of floats representing the total load (consumption) in watts for different time intervals. +- **Type**: Array +- **Element Type**: Float +- **Length**: 48 + +### `pv_forecast` + +- **Description**: An array of floats representing the forecasted photovoltaic output in watts for different time intervals. +- **Type**: Array +- **Element Type**: Float +- **Length**: 48 + +### `temperature_forecast` + +- **Description**: An array of floats representing the temperature forecast in degrees Celsius for different time intervals. +- **Type**: Array +- **Element Type**: Float +- **Length**: 48 + +### `pv_soc` + +- **Description**: An integer representing the state of charge of the PV battery at the **start** of the current hour (not the current state). +- **Type**: Integer + +### `pv_akku_cap` + +- **Description**: An integer representing the capacity of the photovoltaic battery in watt-hours. +- **Type**: Integer + +### `einspeiseverguetung_euro_pro_wh` + +- **Description**: A float representing the feed-in compensation in euros per watt-hour. +- **Type**: Float + +### `eauto_min_soc` + +- **Description**: An integer representing the minimum state of charge (SOC) of the electric vehicle in percentage. +- **Type**: Integer + +### `eauto_cap` + +- **Description**: An integer representing the capacity of the electric vehicle battery in watt-hours. +- **Type**: Integer + +### `eauto_charge_efficiency` + +- **Description**: A float representing the charging efficiency of the electric vehicle. +- **Type**: Float + +### `eauto_charge_power` + +- **Description**: An integer representing the charging power of the electric vehicle in watts. +- **Type**: Integer + +### `eauto_soc` + +- **Description**: An integer representing the current state of charge (SOC) of the electric vehicle in percentage. +- **Type**: Integer + +### `start_solution` + +- **Description**: Can be `null` or contain a previous solution (if available). +- **Type**: `null` or object + +### `haushaltsgeraet_wh` + +- **Description**: An integer representing the energy consumption of a household device in watt-hours. +- **Type**: Integer + +### `haushaltsgeraet_dauer` + +- **Description**: An integer representing the usage duration of a household device in hours. +- **Type**: Integer + +# JSON Output Description + +This document describes the structure and data types of the JSON output returned by the Flask server, with a forecast period of 48 hours. + +**Note**: The first value of "Last_Wh_pro_Stunde", "Netzeinspeisung_Wh_pro_Stunde" and "Netzbezug_Wh_pro_Stunde", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged. + +## JSON Output Fields (as of 30.7.2024) + +### discharge_hours_bin + +An array that indicates for each hour of the forecast period (in this example, 48 hours) whether energy is discharged from the battery or not. The values are either `0` (no discharge) or `1` (discharge). + +### eauto_obj + +This object contains information related to the electric vehicle and its charging and discharging behavior: + +- **charge_array**: Indicates for each hour whether the EV is charging (`0` for no charging, `1` for charging). + - **Type**: Array + - **Element Type**: Integer (0 or 1) + - **Length**: 48 +- **discharge_array**: Indicates for each hour whether the EV is discharging (`0` for no discharging, `1` for discharging). + - **Type**: Array + - **Element Type**: Integer (0 or 1) + - **Length**: 48 +- **entlade_effizienz**: The discharge efficiency as a float. + - **Type**: Float +- **hours**: Amount of hours the simulation is done for. + - **Type**: Integer +- **kapazitaet_wh**: The capacity of the EV’s battery in watt-hours. + - **Type**: Integer +- **lade_effizienz**: The charging efficiency as a float. + - **Type**: Float +- **max_ladeleistung_w**: The maximum charging power of the EV in watts. + - **Type**: Float +- **max_ladeleistung_w**: Max charging power of the EV in Watts. + - **Type**: Integer +- **soc_wh**: The state of charge of the battery in watt-hours at the start of the simulation. + - **Type**: Integer +- **start_soc_prozent**: The state of charge of the battery in percentage at the start of the simulation. + - **Type**: Integer + +### eautocharge_hours_float + +An array of binary values (0 or 1) that indicates whether the EV will be charged in a certain hour. + +- **Type**: Array +- **Element Type**: Integer (0 or 1) +- **Length**: 48 + +### result + +This object contains the results of the simulation and provides insights into various parameters over the entire forecast period: + +- **E-Auto_SoC_pro_Stunde**: The state of charge of the EV for each hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Eigenverbrauch_Wh_pro_Stunde**: The self-consumption of the system in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Einnahmen_Euro_pro_Stunde**: The revenue from grid feed-in or other sources in euros per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Gesamt_Verluste**: The total losses in watt-hours over the entire period. + - **Type**: Float +- **Gesamtbilanz_Euro**: The total balance of revenues minus costs in euros. + - **Type**: Float +- **Gesamteinnahmen_Euro**: The total revenues in euros. + - **Type**: Float +- **Gesamtkosten_Euro**: The total costs in euros. + - **Type**: Float +- **Haushaltsgeraet_wh_pro_stunde**: The energy consumption of a household appliance in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Kosten_Euro_pro_Stunde**: The costs in euros per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Netzbezug_Wh_pro_Stunde**: The grid energy drawn in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Netzeinspeisung_Wh_pro_Stunde**: The energy fed into the grid in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Verluste_Pro_Stunde**: The losses in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **akku_soc_pro_stunde**: The state of charge of the battery (not the EV) in percentage per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 + +### simulation_data + +An object containing the simulated data. + +- **E-Auto_SoC_pro_Stunde**: An array of floats representing the simulated state of charge of the electric car per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Eigenverbrauch_Wh_pro_Stunde**: An array of floats representing the simulated self-consumption in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Einnahmen_Euro_pro_Stunde**: An array of floats representing the simulated income in euros per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Gesamt_Verluste**: The total simulated losses in watt-hours. + - **Type**: Float +- **Gesamtbilanz_Euro**: The total simulated balance in euros. + - **Type**: Float +- **Gesamteinnahmen_Euro**: The total simulated income in euros. + - **Type**: Float +- **Gesamtkosten_Euro**: The total simulated costs in euros. + - **Type**: Float +- **Haushaltsgeraet_wh_pro_stunde**: An array of floats representing the simulated energy consumption of a household appliance in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Kosten_Euro_pro_Stunde**: An array of floats representing the simulated costs in euros per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Netzbezug_Wh_pro_Stunde**: An array of floats representing the simulated grid consumption in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Netzeinspeisung_Wh_pro_Stunde**: An array of floats representing the simulated grid feed-in in watt-hours per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **Verluste_Pro_Stunde**: An array of floats representing the simulated losses per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 +- **akku_soc_pro_stunde**: An array of floats representing the simulated state of charge of the battery in percentage per hour. + - **Type**: Array + - **Element Type**: Float + - **Length**: 35 + +### spuelstart + +- **Description**: Can be `null` or contain an object representing the start of washing (if applicable). +- **Type**: null or object + +### start_solution + +- **Description**: An array of binary values (0 or 1) representing a possible starting solution for the simulation. +- **Type**: Array +- **Element Type**: Integer (0 or 1) +- **Length**: 48 diff --git a/docker-compose.yaml b/docker-compose.yaml index cdc5ad61..6939c8fb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,22 +1,22 @@ --- networks: eos: - name: 'eos' + name: "eos" services: eos: - image: 'akkudoktor/eos:${EOS_VERSION}' + image: "akkudoktor/eos:${EOS_VERSION}" read_only: true build: context: . - dockerfile: 'Dockerfile' + dockerfile: "Dockerfile" args: - PYTHON_VERSION: '${PYTHON_VERSION}' + PYTHON_VERSION: "${PYTHON_VERSION}" init: true environment: - FLASK_RUN_PORT: '${EOS_PORT}' + FLASK_RUN_PORT: "${EOS_PORT}" networks: - - 'eos' + - "eos" volumes: - ./src/akkudoktoreos/config.py:/opt/eos/akkudoktoreos/config.py:ro ports: - - '${EOS_PORT}:${EOS_PORT}' + - "${EOS_PORT}:${EOS_PORT}" From 685e855ddc9e500ac908e5d352c32bb6ff88afd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 23:15:18 +0000 Subject: [PATCH 10/21] Bump sphinx from 8.0.2 to 8.1.3 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 8.0.2 to 8.1.3. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v8.0.2...v8.1.3) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ff90b268..6118b1e2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -r requirements.txt myst-parser==4.0.0 -sphinx==8.0.2 +sphinx==8.1.3 sphinx_rtd_theme==3.0.1 sphinxcontrib-openapi==0.8.4 pytest==8.3.3 From 1f50eb545d54fc4f4fda71a379054a0b4faedde3 Mon Sep 17 00:00:00 2001 From: Normann Date: Thu, 10 Oct 2024 23:38:34 +0200 Subject: [PATCH 11/21] test_load_corrector --- src/akkudoktoreos/class_load_corrector.py | 260 ++++++++++++++-------- tests/test_load_corrector.py | 198 ++++++++++++++++ 2 files changed, 368 insertions(+), 90 deletions(-) create mode 100644 tests/test_load_corrector.py diff --git a/src/akkudoktoreos/class_load_corrector.py b/src/akkudoktoreos/class_load_corrector.py index 2499c437..7c2cb6a2 100644 --- a/src/akkudoktoreos/class_load_corrector.py +++ b/src/akkudoktoreos/class_load_corrector.py @@ -1,3 +1,5 @@ +from typing import Optional + import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -5,29 +7,63 @@ 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 + def __init__( + self, + measured_data: pd.DataFrame, + predicted_data: pd.DataFrame, + load_forecast: object, + ) -> None: + """ + Initialize the LoadPredictionAdjuster with measured, predicted data, and a load forecast object. + """ + # Store the input dataframes + self.measured_data: pd.DataFrame = measured_data + self.predicted_data: pd.DataFrame = predicted_data + self.load_forecast: object = load_forecast + + # Merge measured and predicted data + self.merged_data: pd.DataFrame = self._merge_data() + + # Initialize placeholders for train/test data and differences + self.train_data: Optional[pd.DataFrame] = None + self.test_data: Optional[pd.DataFrame] = None + self.weekday_diff: Optional[pd.Series] = None + self.weekend_diff: Optional[pd.Series] = None + + def _remove_outliers( + self, data: pd.DataFrame, threshold: float = 2.0 + ) -> pd.DataFrame: + """ + Remove outliers based on the Z-score from the 'Last' column. + + Args: + data (pd.DataFrame): The input data with 'Last' column. + threshold (float): The Z-score threshold for detecting outliers. + + Returns: + pd.DataFrame: Filtered data without outliers. + """ + # Calculate Z-score for 'Last' column and filter based on threshold + data["Z-Score"] = np.abs( + (data["Last"] - data["Last"].mean()) / data["Last"].std() + ) 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 + return filtered_data.drop( + columns=["Z-Score"] + ) # Drop Z-score column after filtering + + def _merge_data(self) -> pd.DataFrame: + """ + Merge the measured and predicted data on the 'time' column. + + Returns: + pd.DataFrame: The merged dataset. + """ + # Convert time columns to datetime in both datasets 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 + # Localize time to UTC and then convert to Berlin time if self.measured_data["time"].dt.tz is None: self.measured_data["time"] = self.measured_data["time"].dt.tz_localize("UTC") @@ -36,20 +72,37 @@ def _merge_data(self): ) self.measured_data["time"] = self.measured_data["time"].dt.tz_convert("Europe/Berlin") - # Optionally: Remove timezone information if only working locally + # Remove timezone information (optional for local work) 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) + # Merge the measured and predicted dataframes on 'time' + merged_data = pd.merge( + self.measured_data, self.predicted_data, on="time", how="inner" + ) + + # Extract useful columns such as 'Hour' and 'DayOfWeek' 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): + def calculate_weighted_mean( + self, train_period_weeks: int = 9, test_period_weeks: int = 1 + ) -> None: + """ + Calculate the weighted mean difference between actual and predicted values for training and testing periods. + + Args: + train_period_weeks (int): Number of weeks to use for training data. + test_period_weeks (int): Number of weeks to use for testing data. + """ + # Remove outliers from the merged data self.merged_data = self._remove_outliers(self.merged_data) - train_end_date = self.merged_data["time"].max() - pd.Timedelta(weeks=test_period_weeks) + + # Define training and testing periods based on weeks + 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) @@ -57,21 +110,26 @@ def calculate_weighted_mean(self, train_period_weeks=9, test_period_weeks=1): test_start_date + pd.Timedelta(weeks=test_period_weeks) - pd.Timedelta(hours=1) ) + # Split merged data into training and testing datasets 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"] + # Calculate the difference between actual ('Last') and predicted ('Last Pred') + self.train_data["Difference"] = ( + self.train_data["Last"] - self.train_data["Last Pred"] + ) + # Separate training data into weekdays and weekends weekdays_train_data = self.train_data[self.train_data["DayOfWeek"] < 5] weekends_train_data = self.train_data[self.train_data["DayOfWeek"] >= 5] + # Calculate weighted mean differences for both weekdays and weekends self.weekday_diff = ( weekdays_train_data.groupby("Hour").apply(self._weighted_mean_diff).dropna() ) @@ -79,27 +137,64 @@ def calculate_weighted_mean(self, train_period_weeks=9, test_period_weeks=1): weekends_train_data.groupby("Hour").apply(self._weighted_mean_diff).dropna() ) - def _weighted_mean_diff(self, data): + def _weighted_mean_diff(self, data: pd.DataFrame) -> float: + """ + Compute the weighted mean difference between actual and predicted values. + + Args: + data (pd.DataFrame): Data for a specific hour. + + Returns: + float: Weighted mean difference for that hour. + """ + # Weigh recent data more by using days difference from the last date in the training set 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) + def adjust_predictions(self) -> None: + """ + Adjust predictions for both training and test data using the calculated weighted differences. + """ + # Apply adjustments to both training and testing data + 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): + def _adjust_row(self, row: pd.Series) -> float: + """ + Adjust a single row's prediction based on the hour and day of the week. + + Args: + row (pd.Series): A single row of data. + + Returns: + float: Adjusted prediction. + """ + # Adjust predictions based on whether it's a weekday or weekend 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): + def plot_results(self) -> None: + """ + Plot the actual, predicted, and adjusted predicted values for both training and testing data. + """ + # Plot results for training and testing data self._plot_data(self.train_data, "Training") self._plot_data(self.test_data, "Testing") - def _plot_data(self, data, data_type): + def _plot_data(self, data: pd.DataFrame, data_type: str) -> None: + """ + Helper function to plot the data. + + Args: + data (pd.DataFrame): Data to plot (training or testing). + data_type (str): Label to identify whether it's training or testing data. + """ plt.figure(figsize=(14, 7)) plt.plot(data["time"], data["Last"], label=f"Actual Last - {data_type}", color="blue") plt.plot( @@ -123,76 +218,61 @@ def _plot_data(self, data, data_type): plt.grid(True) plt.show() - def evaluate_model(self): - mse = mean_squared_error(self.test_data["Last"], self.test_data["Adjusted Pred"]) + def evaluate_model(self) -> None: + """ + Evaluate the model performance using Mean Squared Error and R-squared metrics. + """ + # Calculate Mean Squared Error and R-squared for the adjusted predictions + 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): + def predict_next_hours(self, hours_ahead: int) -> pd.DataFrame: + """ + Predict load for the next given number of hours. + + Args: + hours_ahead (int): Number of hours to predict. + + Returns: + pd.DataFrame: DataFrame with future predicted and adjusted load. + """ + # Get the latest time in the merged data last_date = self.merged_data["time"].max() - future_dates = [last_date + pd.Timedelta(hours=i) for i in range(1, hours_ahead + 1)] + + # Generate future timestamps for the next 'hours_ahead' + future_dates = [ + last_date + pd.Timedelta(hours=i) for i in range(1, hours_ahead + 1) + ] future_df = pd.DataFrame({"time": future_dates}) + + # Extract hour and day of the week for the future predictions future_df["Hour"] = future_df["time"].dt.hour future_df["DayOfWeek"] = future_df["time"].dt.dayofweek + + # Predict the load and apply adjustments for future predictions 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): + def _forecast_next_hours(self, timestamp: pd.Timestamp) -> float: + """ + Helper function to forecast the load for the next hours using the load_forecast object. + + Args: + timestamp (pd.Timestamp): The time for which to predict the load. + + Returns: + float: Predicted load for the given time. + """ + # Use the load_forecast object to get the hourly forecast for the given 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) + # Return forecast for the specific hour, or NaN if hour is out of range + return daily_forecast[0][hour] if hour < len(daily_forecast[0]) else np.nan diff --git a/tests/test_load_corrector.py b/tests/test_load_corrector.py new file mode 100644 index 00000000..491b82cd --- /dev/null +++ b/tests/test_load_corrector.py @@ -0,0 +1,198 @@ +from unittest.mock import MagicMock + +import numpy as np +import pandas as pd +import pytest + +from akkudoktoreos.class_load_corrector import LoadPredictionAdjuster + + +@pytest.fixture +def setup_data() -> tuple[pd.DataFrame, pd.DataFrame, MagicMock]: + """ + Fixture to create mock measured_data, predicted_data, and a mock load_forecast. + These mocks are returned as a tuple for testing purposes. + """ + # Create mock measured_data (real measured load data) + measured_data = pd.DataFrame( + { + "time": pd.date_range(start="2023-10-01", periods=24, freq="H"), + "Last": np.random.rand(24) * 100, # Random measured load values + } + ) + + # Create mock predicted_data (forecasted load data) + predicted_data = pd.DataFrame( + { + "time": pd.date_range(start="2023-10-01", periods=24, freq="H"), + "Last Pred": np.random.rand(24) * 100, # Random predicted load values + } + ) + + # Mock the load_forecast object + load_forecast = MagicMock() + load_forecast.get_daily_stats = MagicMock( + return_value=([np.random.rand(24) * 100],) # Simulate daily statistics + ) + + return measured_data, predicted_data, load_forecast + + +def test_merge_data(setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock]) -> None: + """ + Test the _merge_data method to ensure it merges measured and predicted data correctly. + """ + measured_data, predicted_data, load_forecast = setup_data + adjuster = LoadPredictionAdjuster(measured_data, predicted_data, load_forecast) + + # Call the method to merge data + merged_data = adjuster._merge_data() + + # Assert the merged data is a DataFrame + assert isinstance(merged_data, pd.DataFrame), "Merged data should be a DataFrame" + # Assert certain columns are present in the merged data + assert "Hour" in merged_data.columns, "Merged data should contain 'Hour' column" + assert ( + "DayOfWeek" in merged_data.columns + ), "Merged data should contain 'DayOfWeek' column" + assert len(merged_data) > 0, "Merged data should not be empty" + + +def test_remove_outliers( + setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock], +) -> None: + """ + Test the _remove_outliers method to ensure it filters outliers from the data. + """ + measured_data, predicted_data, load_forecast = setup_data + adjuster = LoadPredictionAdjuster(measured_data, predicted_data, load_forecast) + + # Create data with explicit outliers for testing + normal_values = np.random.rand(98) * 100 # Normal load values + outliers = np.array([500, -500]) # Explicit extreme outlier values + data_with_outliers = np.concatenate([normal_values, outliers]) + + # Simulate the merged_data with outliers to test the _remove_outliers method + adjuster.merged_data = pd.DataFrame({"Last": data_with_outliers}) + + # Apply the _remove_outliers method with default threshold + filtered_data = adjuster._remove_outliers(adjuster.merged_data) + + # Assert that the output is a DataFrame and that outliers were removed + assert isinstance( + filtered_data, pd.DataFrame + ), "Filtered data should be a DataFrame" + assert len(filtered_data) < len( + adjuster.merged_data + ), "Filtered data should remove some outliers" + assert ( + len(filtered_data) == 98 + ), "Filtered data should have removed exactly 2 outliers" + + +def test_calculate_weighted_mean( + setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock], +) -> None: + """ + Test the calculate_weighted_mean method to ensure weighted means for weekday and weekend differences are calculated correctly. + """ + measured_data, predicted_data, load_forecast = setup_data + + # Create time range and new data for 14 days (2 weeks) + time_range = pd.date_range(start="2023-09-25", periods=24 * 14, freq="H") + + # Create new measured_data and predicted_data matching the time range + measured_data = pd.DataFrame( + { + "time": time_range, + "Last": np.random.rand(len(time_range)) * 100, # Random 'Last' values + } + ) + + predicted_data = pd.DataFrame( + { + "time": time_range, + "Last Pred": np.random.rand(len(time_range)) + * 100, # Random 'Last Pred' values + } + ) + + adjuster = LoadPredictionAdjuster(measured_data, predicted_data, load_forecast) + adjuster.merged_data = adjuster._merge_data() + + # Calculate the weighted mean over training and testing periods + adjuster.calculate_weighted_mean(train_period_weeks=1, test_period_weeks=1) + + # Assert that weekday and weekend differences are calculated and non-empty + assert adjuster.weekday_diff is not None, "Weekday differences should be calculated" + assert adjuster.weekend_diff is not None, "Weekend differences should be calculated" + assert len(adjuster.weekday_diff) > 0, "Weekday differences should not be empty" + assert len(adjuster.weekend_diff) > 0, "Weekend differences should not be empty" + + +def test_adjust_predictions( + setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock], +) -> None: + """ + Test the adjust_predictions method to ensure it correctly adds the 'Adjusted Pred' column to train and test data. + """ + measured_data, predicted_data, load_forecast = setup_data + adjuster = LoadPredictionAdjuster(measured_data, predicted_data, load_forecast) + adjuster.merged_data = adjuster._merge_data() + + # Calculate the weighted mean and adjust predictions + adjuster.calculate_weighted_mean(train_period_weeks=1, test_period_weeks=1) + adjuster.adjust_predictions() + + # Assert that the 'Adjusted Pred' column is present in both train and test data + assert ( + "Adjusted Pred" in adjuster.train_data.columns + ), "Train data should have 'Adjusted Pred' column" + assert ( + "Adjusted Pred" in adjuster.test_data.columns + ), "Test data should have 'Adjusted Pred' column" + + +def test_evaluate_model( + setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock], + capsys: pytest.CaptureFixture, +) -> None: + """ + Test the evaluate_model method to ensure it prints evaluation metrics (MSE and R-squared). + """ + measured_data, predicted_data, load_forecast = setup_data + adjuster = LoadPredictionAdjuster(measured_data, predicted_data, load_forecast) + adjuster.merged_data = adjuster._merge_data() + + # Calculate weighted mean, adjust predictions, and evaluate the model + adjuster.calculate_weighted_mean(train_period_weeks=1, test_period_weeks=1) + adjuster.adjust_predictions() + adjuster.evaluate_model() + + # Capture printed output and assert that evaluation metrics are printed + captured = capsys.readouterr() + assert ( + "Mean Squared Error" in captured.out + ), "Evaluation should print Mean Squared Error" + assert "R-squared" in captured.out, "Evaluation should print R-squared" + + +def test_predict_next_hours( + setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock], +) -> None: + """ + Test the predict_next_hours method to ensure future predictions are made and contain 'Adjusted Pred'. + """ + measured_data, predicted_data, load_forecast = setup_data + adjuster = LoadPredictionAdjuster(measured_data, predicted_data, load_forecast) + adjuster.merged_data = adjuster._merge_data() + + # Calculate weighted mean and predict the next 5 hours + adjuster.calculate_weighted_mean(train_period_weeks=1, test_period_weeks=1) + future_df = adjuster.predict_next_hours(5) + + # Assert that the correct number of future hours are predicted and that 'Adjusted Pred' is present + assert len(future_df) == 5, "Should predict for 5 future hours" + assert ( + "Adjusted Pred" in future_df.columns + ), "Future data should have 'Adjusted Pred' column" From e08e2b52690750eb9e6f718ec205d882f58c2476 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Mon, 7 Oct 2024 20:56:10 +0200 Subject: [PATCH 12/21] Streamline Dockerfile, remove unused deps * Dockerfile: Use non-root user, buildx cache, setup for readonly container, remove unused apt deps. For now don't install pip package and keep development flask server as this will be replaced in the future (fastapi). Then a proper webserver (e.g. nginx) should be used and the pip package can be created and deployed just to the run-stage (with the webserver). * docker-compose: Set to readonly (anonymous volumes declared in Dockerfile should maintain all writable data). Mount config.py for easier development. Should be replaced by environment support for all config file variables. * Remove unused runtime dependencies: mariadb, joblib, pytest, pytest-cov. * Move pytest-cov to dev dependencies. * Add output_dir to config.py. * Fix visualization_results.pdf endpoint. * Update docs. --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 0d0d565a..771fa82e 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,6 @@ help: @echo " docker-build - Rebuild docker image" @echo " docs - Generate HTML documentation (in build/docs/html/)." @echo " read-docs - Read HTML documentation in your browser." - @echo " read-docs - Read HTML documentation in your browser." @echo " run - Run flask_server in the virtual environment (needs install before)." @echo " dist - Create distribution (in dist/)." @echo " clean - Remove generated documentation, distribution and virtual environment." From 09235843207b8b99ce6b989215dd3caa8645c317 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 10 Oct 2024 15:00:32 +0200 Subject: [PATCH 13/21] Ruff format --- src/akkudoktoreos/class_haushaltsgeraet.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/akkudoktoreos/class_haushaltsgeraet.py b/src/akkudoktoreos/class_haushaltsgeraet.py index e52b7a8b..3a64d5e3 100644 --- a/src/akkudoktoreos/class_haushaltsgeraet.py +++ b/src/akkudoktoreos/class_haushaltsgeraet.py @@ -16,9 +16,7 @@ class HaushaltsgeraetParameters(BaseModel): class Haushaltsgeraet: def __init__(self, parameters: HaushaltsgeraetParameters, hours=24): self.hours = hours # Total duration for which the planning is done - self.verbrauch_wh = ( - parameters.verbrauch_wh # Total energy consumption of the device in kWh - ) + self.verbrauch_wh = parameters.verbrauch_wh # Total energy consumption of the device in kWh self.dauer_h = parameters.dauer_h # Duration of use in hours self.lastkurve = np.zeros(self.hours) # Initialize the load curve with zeros From 7763b1fef8917f4987645cb2a2941caa44ad771c Mon Sep 17 00:00:00 2001 From: Normann Date: Fri, 11 Oct 2024 10:40:05 +0200 Subject: [PATCH 14/21] rebase fixes --- src/akkudoktoreos/class_load_corrector.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/akkudoktoreos/class_load_corrector.py b/src/akkudoktoreos/class_load_corrector.py index 7c2cb6a2..93b40f64 100644 --- a/src/akkudoktoreos/class_load_corrector.py +++ b/src/akkudoktoreos/class_load_corrector.py @@ -52,6 +52,15 @@ def _remove_outliers( columns=["Z-Score"] ) # Drop Z-score column after filtering + def _merge_data(self) -> pd.DataFrame: + """ + Merge the measured and predicted data on the 'time' column. + + Returns: + pd.DataFrame: The merged dataset. + """ + # Convert time columns to datetime in both datasets + def _merge_data(self) -> pd.DataFrame: """ Merge the measured and predicted data on the 'time' column. @@ -63,6 +72,7 @@ def _merge_data(self) -> pd.DataFrame: self.predicted_data["time"] = pd.to_datetime(self.predicted_data["time"]) self.measured_data["time"] = pd.to_datetime(self.measured_data["time"]) + # Localize time to UTC and then convert to Berlin time # Localize time to UTC and then convert to Berlin time if self.measured_data["time"].dt.tz is None: self.measured_data["time"] = self.measured_data["time"].dt.tz_localize("UTC") @@ -72,6 +82,7 @@ def _merge_data(self) -> pd.DataFrame: ) self.measured_data["time"] = self.measured_data["time"].dt.tz_convert("Europe/Berlin") + # Remove timezone information (optional for local work) # Remove timezone information (optional for local work) self.predicted_data["time"] = self.predicted_data["time"].dt.tz_localize(None) self.measured_data["time"] = self.measured_data["time"].dt.tz_localize(None) @@ -110,6 +121,7 @@ def calculate_weighted_mean( test_start_date + pd.Timedelta(weeks=test_period_weeks) - pd.Timedelta(hours=1) ) + # Split merged data into training and testing datasets # Split merged data into training and testing datasets self.train_data = self.merged_data[ (self.merged_data["time"] >= train_start_date) @@ -125,10 +137,12 @@ def calculate_weighted_mean( self.train_data["Last"] - self.train_data["Last Pred"] ) + # Separate training data into weekdays and weekends # Separate training data into weekdays and weekends weekdays_train_data = self.train_data[self.train_data["DayOfWeek"] < 5] weekends_train_data = self.train_data[self.train_data["DayOfWeek"] >= 5] + # Calculate weighted mean differences for both weekdays and weekends # Calculate weighted mean differences for both weekdays and weekends self.weekday_diff = ( weekdays_train_data.groupby("Hour").apply(self._weighted_mean_diff).dropna() @@ -249,10 +263,14 @@ def predict_next_hours(self, hours_ahead: int) -> pd.DataFrame: ] future_df = pd.DataFrame({"time": future_dates}) + # Extract hour and day of the week for the future predictions + # Extract hour and day of the week for the future predictions future_df["Hour"] = future_df["time"].dt.hour future_df["DayOfWeek"] = future_df["time"].dt.dayofweek + # Predict the load and apply adjustments for future predictions + # Predict the load and apply adjustments for future predictions future_df["Last Pred"] = future_df["time"].apply(self._forecast_next_hours) future_df["Adjusted Pred"] = future_df.apply(self._adjust_row, axis=1) @@ -274,5 +292,7 @@ def _forecast_next_hours(self, timestamp: pd.Timestamp) -> float: hour = timestamp.hour daily_forecast = self.load_forecast.get_daily_stats(date_str) + # Return forecast for the specific hour, or NaN if hour is out of range + # Return forecast for the specific hour, or NaN if hour is out of range return daily_forecast[0][hour] if hour < len(daily_forecast[0]) else np.nan From 2952cdaf689bd795a709c1a7b74d911b6c772a95 Mon Sep 17 00:00:00 2001 From: Normann Date: Fri, 11 Oct 2024 10:53:28 +0200 Subject: [PATCH 15/21] ruff changes --- src/akkudoktoreos/class_load_corrector.py | 36 ++++++----------------- tests/test_load_corrector.py | 23 ++++----------- 2 files changed, 15 insertions(+), 44 deletions(-) diff --git a/src/akkudoktoreos/class_load_corrector.py b/src/akkudoktoreos/class_load_corrector.py index 93b40f64..7e98ed8c 100644 --- a/src/akkudoktoreos/class_load_corrector.py +++ b/src/akkudoktoreos/class_load_corrector.py @@ -30,9 +30,7 @@ def __init__( self.weekday_diff: Optional[pd.Series] = None self.weekend_diff: Optional[pd.Series] = None - def _remove_outliers( - self, data: pd.DataFrame, threshold: float = 2.0 - ) -> pd.DataFrame: + def _remove_outliers(self, data: pd.DataFrame, threshold: float = 2.0) -> pd.DataFrame: """ Remove outliers based on the Z-score from the 'Last' column. @@ -44,13 +42,9 @@ def _remove_outliers( pd.DataFrame: Filtered data without outliers. """ # Calculate Z-score for 'Last' column and filter based on threshold - data["Z-Score"] = np.abs( - (data["Last"] - data["Last"].mean()) / data["Last"].std() - ) + data["Z-Score"] = np.abs((data["Last"] - data["Last"].mean()) / data["Last"].std()) filtered_data = data[data["Z-Score"] < threshold] - return filtered_data.drop( - columns=["Z-Score"] - ) # Drop Z-score column after filtering + return filtered_data.drop(columns=["Z-Score"]) # Drop Z-score column after filtering def _merge_data(self) -> pd.DataFrame: """ @@ -88,9 +82,7 @@ def _merge_data(self) -> pd.DataFrame: self.measured_data["time"] = self.measured_data["time"].dt.tz_localize(None) # Merge the measured and predicted dataframes on 'time' - merged_data = pd.merge( - self.measured_data, self.predicted_data, on="time", how="inner" - ) + merged_data = pd.merge(self.measured_data, self.predicted_data, on="time", how="inner") # Extract useful columns such as 'Hour' and 'DayOfWeek' merged_data["Hour"] = merged_data["time"].dt.hour @@ -111,9 +103,7 @@ def calculate_weighted_mean( self.merged_data = self._remove_outliers(self.merged_data) # Define training and testing periods based on weeks - train_end_date = self.merged_data["time"].max() - pd.Timedelta( - weeks=test_period_weeks - ) + 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) @@ -133,9 +123,7 @@ def calculate_weighted_mean( ] # Calculate the difference between actual ('Last') and predicted ('Last Pred') - self.train_data["Difference"] = ( - self.train_data["Last"] - self.train_data["Last Pred"] - ) + self.train_data["Difference"] = self.train_data["Last"] - self.train_data["Last Pred"] # Separate training data into weekdays and weekends # Separate training data into weekdays and weekends @@ -172,9 +160,7 @@ def adjust_predictions(self) -> None: Adjust predictions for both training and test data using the calculated weighted differences. """ # Apply adjustments to both training and testing data - self.train_data["Adjusted Pred"] = self.train_data.apply( - self._adjust_row, axis=1 - ) + 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: pd.Series) -> float: @@ -237,9 +223,7 @@ def evaluate_model(self) -> None: Evaluate the model performance using Mean Squared Error and R-squared metrics. """ # Calculate Mean Squared Error and R-squared for the adjusted predictions - mse = mean_squared_error( - self.test_data["Last"], self.test_data["Adjusted Pred"] - ) + 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}") @@ -258,9 +242,7 @@ def predict_next_hours(self, hours_ahead: int) -> pd.DataFrame: last_date = self.merged_data["time"].max() # Generate future timestamps for the next 'hours_ahead' - future_dates = [ - last_date + pd.Timedelta(hours=i) for i in range(1, hours_ahead + 1) - ] + future_dates = [last_date + pd.Timedelta(hours=i) for i in range(1, hours_ahead + 1)] future_df = pd.DataFrame({"time": future_dates}) # Extract hour and day of the week for the future predictions diff --git a/tests/test_load_corrector.py b/tests/test_load_corrector.py index 491b82cd..eb9c7e4f 100644 --- a/tests/test_load_corrector.py +++ b/tests/test_load_corrector.py @@ -52,9 +52,7 @@ def test_merge_data(setup_data: tuple[pd.DataFrame, pd.DataFrame, MagicMock]) -> assert isinstance(merged_data, pd.DataFrame), "Merged data should be a DataFrame" # Assert certain columns are present in the merged data assert "Hour" in merged_data.columns, "Merged data should contain 'Hour' column" - assert ( - "DayOfWeek" in merged_data.columns - ), "Merged data should contain 'DayOfWeek' column" + assert "DayOfWeek" in merged_data.columns, "Merged data should contain 'DayOfWeek' column" assert len(merged_data) > 0, "Merged data should not be empty" @@ -79,15 +77,11 @@ def test_remove_outliers( filtered_data = adjuster._remove_outliers(adjuster.merged_data) # Assert that the output is a DataFrame and that outliers were removed - assert isinstance( - filtered_data, pd.DataFrame - ), "Filtered data should be a DataFrame" + assert isinstance(filtered_data, pd.DataFrame), "Filtered data should be a DataFrame" assert len(filtered_data) < len( adjuster.merged_data ), "Filtered data should remove some outliers" - assert ( - len(filtered_data) == 98 - ), "Filtered data should have removed exactly 2 outliers" + assert len(filtered_data) == 98, "Filtered data should have removed exactly 2 outliers" def test_calculate_weighted_mean( @@ -112,8 +106,7 @@ def test_calculate_weighted_mean( predicted_data = pd.DataFrame( { "time": time_range, - "Last Pred": np.random.rand(len(time_range)) - * 100, # Random 'Last Pred' values + "Last Pred": np.random.rand(len(time_range)) * 100, # Random 'Last Pred' values } ) @@ -171,9 +164,7 @@ def test_evaluate_model( # Capture printed output and assert that evaluation metrics are printed captured = capsys.readouterr() - assert ( - "Mean Squared Error" in captured.out - ), "Evaluation should print Mean Squared Error" + assert "Mean Squared Error" in captured.out, "Evaluation should print Mean Squared Error" assert "R-squared" in captured.out, "Evaluation should print R-squared" @@ -193,6 +184,4 @@ def test_predict_next_hours( # Assert that the correct number of future hours are predicted and that 'Adjusted Pred' is present assert len(future_df) == 5, "Should predict for 5 future hours" - assert ( - "Adjusted Pred" in future_df.columns - ), "Future data should have 'Adjusted Pred' column" + assert "Adjusted Pred" in future_df.columns, "Future data should have 'Adjusted Pred' column" From 676b4d84b388b57a43a0df80a8f1a30ab913ce93 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Fri, 11 Oct 2024 22:46:55 +0200 Subject: [PATCH 16/21] Cleanup: Fix violin chart labels, remove debug code --- src/akkudoktoreos/visualize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/akkudoktoreos/visualize.py b/src/akkudoktoreos/visualize.py index 2d9a6d0e..2c6ddcb2 100644 --- a/src/akkudoktoreos/visualize.py +++ b/src/akkudoktoreos/visualize.py @@ -270,7 +270,7 @@ def visualisiere_ergebnisse( # First violin plot for losses axs[0].violinplot(data[0], positions=[1], showmeans=True, showmedians=True) - axs[1].set(title="Losses", xticks=[1], xticklabels=["Losses"]) + axs[0].set(title="Losses", xticks=[1], xticklabels=["Losses"]) # Second violin plot for balance axs[1].violinplot(data[1], positions=[1], showmeans=True, showmedians=True) From 4186937e7f479f93a10245d0e0e7ab448866816c Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Fri, 11 Oct 2024 14:21:23 +0200 Subject: [PATCH 17/21] Add documentation to class_pv_forecast.py. Added documentation. Beware mostly generated by ChatGPT. Signed-off-by: Bobby Noelte --- src/akkudoktoreos/class_pv_forecast.py | 219 ++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 6 deletions(-) diff --git a/src/akkudoktoreos/class_pv_forecast.py b/src/akkudoktoreos/class_pv_forecast.py index 8badaa2a..9ad2d6b3 100644 --- a/src/akkudoktoreos/class_pv_forecast.py +++ b/src/akkudoktoreos/class_pv_forecast.py @@ -1,3 +1,40 @@ +"""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 usage: + # Initialize PVForecast class with a URL + forecast = PVForecast( + prediction_hours=24, + url="https://api.akkudoktor.net/forecast?lat=50.8588&lon=7.3747..." + ) + + # 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: + cache_dir (str): The directory where cached data is stored. Defaults to 'cache'. + prediction_hours (int): Number of forecast hours. Defaults to 48. +""" + import hashlib import json import os @@ -17,6 +54,17 @@ class ForecastResponse(BaseModel): 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, @@ -26,6 +74,16 @@ def __init__( temperature=None, ac_power_measurement=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 @@ -34,29 +92,87 @@ def __init__( self.ac_power_measurement = ac_power_measurement def get_date_time(self): + """Returns the forecast date and time. + + Returns: + datetime: The date and time of the forecast. + """ return self.date_time def get_dc_power(self): + """Returns the DC power. + + Returns: + float: DC power in watts. + """ return self.dc_power def ac_power_measurement(self): + """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): + """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): + """Returns the wind speed at 10 meters altitude. + + Returns: + float: Wind speed in meters per second. + """ return self.windspeed_10m def get_temperature(self): + """Returns the temperature. + + Returns: + float: Temperature in degrees Celsius. + """ return self.temperature class PVForecast: + """Manages PV power forecasts and weather data. + + Attributes: + meta (dict): Metadata of the forecast. + forecast_data (list): List of ForecastData objects. + cache_dir (str): Directory for cached data. + prediction_hours (int): Number of hours for which the forecast is made. + current_measurement (float): Current AC power measurement. + """ + def __init__(self, filepath=None, url=None, cache_dir="cache", prediction_hours=48): + """Initializes the PVForecast instance. + + Loads data either from a file or from a URL. + + Args: + filepath (str, optional): Path to the JSON file with forecast data. Defaults to None. + url (str, optional): URL to the API providing forecast data. Defaults to None. + cache_dir (str, optional): Directory for cache data. Defaults to "cache". + prediction_hours (int, optional): Number of hours to forecast. Defaults to 48. + + Raises: + ValueError: If the forecasted data is less than `prediction_hours`. + """ self.meta = {} self.forecast_data = [] self.cache_dir = cache_dir @@ -72,10 +188,20 @@ def __init__(self, filepath=None, url=None, cache_dir="cache", prediction_hours= if len(self.forecast_data) < self.prediction_hours: raise ValueError( - f"Die Vorhersage muss mindestens {self.prediction_hours} Stunden umfassen, aber es wurden nur {len(self.forecast_data)} Stunden vorhergesagt." + f"The forecast must cover at least {self.prediction_hours} hours, " + f"but only {len(self.forecast_data)} hours were predicted." ) def update_ac_power_measurement(self, date_time=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 = date_time.replace(minute=0, second=0, microsecond=0) @@ -90,6 +216,11 @@ def update_ac_power_measurement(self, date_time=None, ac_power_measurement=None) return found def process_data(self, data): + """Processes JSON data and stores the forecasts. + + Args: + data (dict): JSON data containing forecast values. + """ self.meta = data.get("meta", {}) all_values = data.get("values", []) @@ -119,11 +250,21 @@ def process_data(self, data): self.forecast_data.append(forecast) def load_data_from_file(self, filepath): + """Loads forecast data from a file. + + Args: + filepath (str): Path to the file containing the forecast data. + """ with open(filepath, "r") as file: data = json.load(file) self.process_data(data) def load_data_from_url(self, url): + """Loads forecast data from a URL. + + Args: + url (str): URL of the API providing forecast data. + """ response = requests.get(url) if response.status_code == 200: data = response.json() @@ -134,6 +275,11 @@ def load_data_from_url(self, url): self.load_data_from_url(url) def load_data_with_caching(self, url): + """Loads data from a URL or from the cache if available. + + Args: + url (str): URL of the API providing forecast data. + """ date = datetime.now().strftime("%Y-%m-%d") cache_file = os.path.join(self.cache_dir, self.generate_cache_filename(url, date)) @@ -154,13 +300,35 @@ def load_data_with_caching(self, url): self.process_data(data) def generate_cache_filename(self, url, date): + """Generates a cache filename based on the URL and date. + + Args: + url (str): URL of the API. + date (str): Date in the format YYYY-MM-DD. + + Returns: + str: Generated cache filename. + """ cache_key = hashlib.sha256(f"{url}{date}".encode("utf-8")).hexdigest() return f"cache_{cache_key}.json" 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_str): + """Returns the temperature forecast for a specific date. + + Args: + input_date_str (str): Date in the format YYYY-MM-DD. + + Returns: + np.array: Array of temperature forecasts. + """ input_date = datetime.strptime(input_date_str, "%Y-%m-%d") daily_forecast_obj = [ data @@ -174,6 +342,15 @@ def get_temperature_forecast_for_date(self, input_date_str): return np.array(daily_forecast) def get_pv_forecast_for_date_range(self, start_date_str, end_date_str): + """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. + """ start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() date_range_forecast = [] @@ -189,6 +366,15 @@ def get_pv_forecast_for_date_range(self, start_date_str, end_date_str): return np.array(ac_power_forecast)[: self.prediction_hours] def get_temperature_for_date_range(self, start_date_str, end_date_str): + """Returns the temperature forecast for a given 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: + np.array: Array containing temperature forecasts for each hour within the date range. + """ start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() date_range_forecast = [] @@ -202,7 +388,12 @@ def get_temperature_for_date_range(self, start_date_str, end_date_str): return np.array(temperature_forecast)[: self.prediction_hours] def get_forecast_dataframe(self): - # Wandelt die Vorhersagedaten in ein Pandas DataFrame um + """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(), @@ -219,19 +410,35 @@ def get_forecast_dataframe(self): return df def print_ac_power_and_measurement(self): - """Druckt die DC-Leistung und den Messwert für jede Stunde.""" + """Prints the DC power, AC power, and AC power measurement for each forecast hour. + + For each forecast entry, it prints the time, DC power, forecasted AC power, + measured AC power (if available), and the value returned by the `get_ac_power` method. + """ for forecast in self.forecast_data: date_time = forecast.date_time print( - f"Zeit: {date_time}, DC: {forecast.dc_power}, AC: {forecast.ac_power}, Messwert: {forecast.ac_power_measurement}, AC GET: {forecast.get_ac_power()}" + f"Zeit: {date_time}, DC: {forecast.dc_power}, AC: {forecast.ac_power}, " + "Messwert: {forecast.ac_power_measurement}, AC GET: {forecast.get_ac_power()}" ) -# Beispiel für die Verwendung der Klasse +# 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=50.8588&lon=7.3747&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", + url="https://api.akkudoktor.net/forecast?lat=50.8588&lon=7.3747&" + "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) forecast.print_ac_power_and_measurement() From 1fbdd18926c7c0bd8195977e3ea3534cede1fa8f Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Tue, 15 Oct 2024 13:51:07 +0200 Subject: [PATCH 18/21] Add CacheFileStore, to_datetime and get_logger utilities. The `CacheFileStore` class is a singleton-based, thread-safe key-value store for managing temporary file objects, allowing the creation, retrieval, and management of cache files. The utility modules offer a flexible logging setup (`get_logger`) and utilities to handle different date-time formats (`to_datetime`, `to_timestamp`). - Cache files are automatically valid for the the current date unless specified otherwise. This is to mimic the current behaviour used in several classes. - The logger supports rotating log files to prevent excessive log file size. - The `to_datetime` and `to_timestamp`functions support a wide variety of input types and formats. They provide the time conversion that is e.g. used in PVForecast. Signed-off-by: Bobby Noelte --- src/akkudoktoreos/cachefilestore.py | 604 ++++++++++++++++++++++++++++ src/akkudoktoreos/datetimeutil.py | 221 ++++++++++ src/akkudoktoreos/logutil.py | 95 +++++ tests/test_util.py | 200 +++++++++ 4 files changed, 1120 insertions(+) create mode 100644 src/akkudoktoreos/cachefilestore.py create mode 100644 src/akkudoktoreos/datetimeutil.py create mode 100644 src/akkudoktoreos/logutil.py create mode 100644 tests/test_util.py diff --git a/src/akkudoktoreos/cachefilestore.py b/src/akkudoktoreos/cachefilestore.py new file mode 100644 index 00000000..cf47b6fd --- /dev/null +++ b/src/akkudoktoreos/cachefilestore.py @@ -0,0 +1,604 @@ +"""cachefilestore.py. + +This module provides a class for in-memory managing of cache files. + +The `CacheFileStore` class is a singleton-based, thread-safe key-value store for managing +temporary file objects, allowing the creation, retrieval, and management of cache files. + +Classes: +-------- +- CacheFileStore: A thread-safe, singleton class for in-memory managing of file-like cache objects. +- CacheFileStoreMeta: Metaclass for enforcing the singleton behavior in `CacheFileStore`. + +Example usage: +-------------- + # CacheFileStore usage + >>> cache_store = CacheFileStore() + >>> cache_store.create('example_key') + >>> cache_file = cache_store.get('example_key') + >>> cache_file.write('Some data') + >>> cache_file.seek(0) + >>> print(cache_file.read()) # Output: 'Some data' + +Notes: +------ +- Cache files are automatically associated with the current date unless specified. +""" + +import hashlib +import inspect +import os +import pickle +import tempfile +import threading +from datetime import date, datetime, time, timedelta +from typing import List, Optional, Union + +from akkudoktoreos.datetimeutil import to_datetime, to_timedelta +from akkudoktoreos.logutil import get_logger + +logger = get_logger(__file__) + + +class CacheFileStoreMeta(type): + """A thread-safe implementation of CacheFileStore.""" + + _instances = {} + + _lock: threading.Lock = threading.Lock() + """Lock object to synchronize threads on first access to CacheFileStore.""" + + def __call__(cls): + """Return CacheFileStore instance.""" + with cls._lock: + if cls not in cls._instances: + instance = super().__call__() + cls._instances[cls] = instance + return cls._instances[cls] + + +class CacheFileStore(metaclass=CacheFileStoreMeta): + """A key-value store that manages file-like tempfile objects to be used as cache files. + + Cache files are associated with a date. If no date is specified, the cache files are + associated with the current date by default. The class provides methods to create + new cache files, retrieve existing ones, delete specific files, and clear all cache + entries. + + CacheFileStore is a thread-safe singleton. Only one store instance will ever be created. + + Attributes: + store (dict): A dictionary that holds the in-memory cache file objects + with their associated keys and dates. + + Example usage: + >>> cache_store = CacheFileStore() + >>> cache_store.create('example_file') + >>> cache_file = cache_store.get('example_file') + >>> cache_file.write('Some data') + >>> cache_file.seek(0) + >>> print(cache_file.read()) # Output: 'Some data' + """ + + def __init__(self): + """Initializes the CacheFileStore instance. + + This constructor sets up an empty key-value store (a dictionary) where each key + corresponds to a cache file that is associated with a given key and an optional date. + """ + self._store = {} + self._store_lock = threading.Lock() + + def _generate_cache_file_key( + self, key: str, until_datetime: Union[datetime, None] + ) -> (str, datetime): + """Generates a unique cache file key based on the key and date. + + The cache file key is a combination of the input key and the date (if provided), + hashed using SHA-256 to ensure uniqueness. + + Args: + key (str): The key that identifies the cache file. + until_datetime (Union[datetime, date, str, int, float, None]): The datetime + until the cache file is valid. The default is the current date at maximum time + (23:59:59). + + Returns: + A tuple of: + str: A hashed string that serves as the unique identifier for the cache file. + datetime: The datetime until the the cache file is valid. + """ + if until_datetime is None: + until_datetime = datetime.combine(date.today(), time.max) + key_datetime = to_datetime(until_datetime, as_string="%Y-%m-%dT%H:%M:%S") + cache_key = hashlib.sha256(f"{key}{key_datetime}".encode("utf-8")).hexdigest() + return (f"{cache_key}", until_datetime) + + def _get_file_path(self, file_obj): + """Retrieve the file path from a file-like object. + + Args: + file_obj: A file-like object (e.g., an instance of + NamedTemporaryFile, BytesIO, StringIO) from which to retrieve the + file path. + + Returns: + str or None: The file path if available, or None if the file-like + object does not provide a file path. + """ + file_path = None + if hasattr(file_obj, "name"): + file_path = file_obj.name # Get the file path from the cache file object + return file_path + + def _search( + self, + key: str, + until_datetime: Union[datetime, date, str, int, float] = None, + at_datetime: Union[datetime, date, str, int, float] = None, + before_datetime: Union[datetime, date, str, int, float] = None, + ): + """Searches for a cached item that matches the key and falls within the datetime range. + + This method looks for a cache item with a key that matches the given `key`, and whose associated + datetime (`cache_file_datetime`) falls on or after the `at_datetime`. If both conditions are met, + it returns the cache item. Otherwise, it returns `None`. + + Args: + key (str): The key to identify the cache item. + until_date (Union[datetime, date, str, int, float, None], optional): The date + until the cache file is valid. Time of day is set to maximum time (23:59:59). + at_datetime (Union[datetime, date, str, int, float], optional): The datetime to compare with + the cache item's datetime. + before_datetime (Union[datetime, date, str, int, float], optional): The datetime to compare + the cache item's datetime to be before. + + Returns: + Optional[tuple]: Returns the cache_file_key, chache_file, cache_file_datetime if found, + otherwise returns `None`. + """ + # Convert input to datetime if they are not None + if until_datetime: + until_datetime = to_datetime(until_datetime) + if at_datetime: + at_datetime = to_datetime(at_datetime) + if before_datetime: + before_datetime = to_datetime(before_datetime) + + for cache_file_key, cache_item in self._store.items(): + cache_file_datetime = cache_item[ + 1 + ] # Extract the datetime associated with the cache item + + # Check if the cache file datetime matches the given criteria + if ( + (until_datetime and until_datetime == cache_file_datetime) + or (at_datetime and at_datetime <= cache_file_datetime) + or (before_datetime and cache_file_datetime < before_datetime) + ): + # This cache file is within the given datetime range + + # Generate a cache file key based on the given key and the cache file datetime + generated_key, _until_dt = self._generate_cache_file_key(key, cache_file_datetime) + + if generated_key == cache_file_key: + # The key matches, return the key and the cache item + return (cache_file_key, cache_item[0], cache_file_datetime) + + # Return None if no matching cache item is found + return None + + def create( + self, + key: str, + until_date: Union[datetime, date, str, int, float, None] = None, + until_datetime: Union[datetime, date, str, int, float, None] = None, + with_ttl: Union[timedelta, str, int, float, None] = None, + mode: str = "wb+", + delete: bool = False, + suffix: Optional[str] = None, + ): + """Creates a new file-like tempfile object associated with the given key. + + If a cache file with the given key and valid timedate already exists, the existing file is + returned. Otherwise, a new tempfile object is created and stored in the key-value store. + + Args: + key (str): The key to store the cache file under. + until_date (Union[datetime, date, str, int, float, None], optional): The date + until the cache file is valid. Time of day is set to maximum time (23:59:59). + until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + until the cache file is valid. Time of day is set to maximum time (23:59:59) if not + provided. + with_ttl (Union[timedelta, str, int, float, None], optional): The time to live that + the cache file is valid. Time starts now. + mode (str, optional): The mode in which the tempfile is opened + (e.g., 'w+', 'r+', 'wb+'). Defaults to 'wb+'. + delete (bool, optional): Whether to delete the file after it is closed. + Defaults to False (keeps the file). + suffix (str, optional): The suffix for the cache file (e.g., '.txt', '.log'). + Defaults to None. + + Returns: + file_obj: A file-like object representing the cache file. + + Example: + >>> cache_file = cache_store.create('example_file', suffix='.txt') + >>> cache_file.write('Some cached data') + >>> cache_file.seek(0) + >>> print(cache_file.read()) # Output: 'Some cached data' + """ + if until_datetime: + until_datetime = to_datetime(until_datetime) + elif with_ttl: + with_ttl = to_timedelta(with_ttl) + until_datetime = datetime.now() + with_ttl + elif until_date: + until_datetime = to_datetime(to_datetime(until_date).date()) + else: + # end of today + until_datetime = datetime.combine(date.today(), time.max) + + cache_file_key, until_date = self._generate_cache_file_key(key, until_datetime) + with self._store_lock: # Synchronize access to _store + if cache_file_key in self._store: + # File already available + cache_file_obj, until_datetime = self._store.get(cache_file_key) + else: + cache_file_obj = tempfile.NamedTemporaryFile( + mode=mode, delete=delete, suffix=suffix + ) + self._store[cache_file_key] = (cache_file_obj, until_datetime) + cache_file_obj.seek(0) + return cache_file_obj + + def set( + self, + key: str, + file_obj, + until_date: Union[datetime, date, str, int, float, None] = None, + until_datetime: Union[datetime, date, str, int, float, None] = None, + with_ttl: Union[timedelta, str, int, float, None] = None, + ): + """Stores a file-like object in the cache under the specified key and date. + + This method allows you to manually set a file-like object into the cache with a specific key + and optional date. + + Args: + key (str): The key to store the file object under. + file_obj: The file-like object. + until_date (Union[datetime, date, str, int, float, None], optional): The date + until the cache file is valid. Time of day is set to maximum time (23:59:59). + until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + until the cache file is valid. Time of day is set to maximum time (23:59:59) if not + provided. + with_ttl (Union[timedelta, str, int, float, None], optional): The time to live that + the cache file is valid. Time starts now. + + Raises: + ValueError: If the key is already in store. + + Example: + >>> cache_store.set('example_file', io.BytesIO(b'Some binary data')) + """ + if until_datetime: + until_datetime = to_datetime(until_datetime) + elif with_ttl: + with_ttl = to_timedelta(with_ttl) + until_datetime = datetime.now() + with_ttl + elif until_date: + until_datetime = to_datetime(to_datetime(until_date).date()) + else: + # end of today + until_datetime = datetime.combine(date.today(), time.max) + + cache_file_key, until_date = self._generate_cache_file_key(key, until_datetime) + with self._store_lock: # Synchronize access to _store + if cache_file_key in self._store: + raise ValueError(f"Key already in store: `{key}`.") + + self._store[cache_file_key] = (file_obj, until_date) + + def get( + self, + key: str, + until_date: Union[datetime, date, str, int, float, None] = None, + until_datetime: Union[datetime, date, str, int, float, None] = None, + at_datetime: Union[datetime, date, str, int, float, None] = None, + before_datetime: Union[datetime, date, str, int, float, None] = None, + ): + """Retrieves the cache file associated with the given key and validity datetime. + + If no cache file is found for the provided key and datetime, the method returns None. + The retrieved file is a file-like object that can be read from or written to. + + Args: + key (str): The key to retrieve the cache file for. + until_date (Union[datetime, date, str, int, float, None], optional): The date + until the cache file is valid. Time of day is set to maximum time (23:59:59). + until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + until the cache file is valid. Time of day is set to maximum time (23:59:59) if not + provided. + at_datetime (Union[datetime, date, str, int, float, None], optional): The datetime the + cache file shall be valid at. Time of day is set to maximum time (23:59:59) if not + provided. Defaults to the current datetime if None is provided. + before_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + to compare the cache files datetime to be before. + + Returns: + file_obj: The file-like cache object, or None if no file is found. + + Example: + >>> cache_file = cache_store.get('example_file') + >>> if cache_file: + >>> cache_file.seek(0) + >>> print(cache_file.read()) # Output: Cached data (if exists) + """ + if until_datetime: + until_datetime = to_datetime(until_datetime) + elif until_date: + until_datetime = to_datetime(to_datetime(until_date).date()) + elif at_datetime: + at_datetime = to_datetime(at_datetime) + elif before_datetime: + before_datetime = to_datetime(before_datetime) + else: + at_datetime = datetime.now() + + with self._store_lock: # Synchronize access to _store + search_item = self._search(key, until_datetime, at_datetime, before_datetime) + if search_item is None: + return None + return search_item[1] + + def delete( + self, + key, + until_date: Union[datetime, date, str, int, float, None] = None, + until_datetime: Union[datetime, date, str, int, float, None] = None, + before_datetime: Union[datetime, date, str, int, float, None] = None, + ): + """Deletes the cache file associated with the given key and datetime. + + This method removes the cache file from the store. + + Args: + key (str): The key of the cache file to delete. + until_date (Union[datetime, date, str, int, float, None], optional): The date + until the cache file is valid. Time of day is set to maximum time (23:59:59). + until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + until the cache file is valid. Time of day is set to maximum time (23:59:59) if not + provided. + before_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + the cache file shall become or be invalid at. Time of day is set to maximum time + (23:59:59) if not provided. Defaults to tommorow start of day. + """ + if until_datetime: + until_datetime = to_datetime(until_datetime) + elif until_date: + until_datetime = to_datetime(to_datetime(until_date).date()) + elif before_datetime: + before_datetime = to_datetime(before_datetime) + else: + today = datetime.now().date() # Get today's date + tomorrow = today + timedelta(days=1) # Add one day to get tomorrow's date + before_datetime = datetime.combine(tomorrow, time.min) + + with self._store_lock: # Synchronize access to _store + search_item = self._search(key, until_datetime, None, before_datetime) + if search_item: + cache_file_key = search_item[0] + cache_file = search_item[1] + cache_file_datetime = search_item[2] + file_path = self._get_file_path(cache_file) + if file_path is None: + logger.warning( + f"The cache file with key '{cache_file_key}' is an in memory " + f"file object. Will only delete store entry but not file." + ) + self._store.pop(cache_file_key) + return + file_path = cache_file.name # Get the file path from the cache file object + del self._store[cache_file_key] + if os.path.exists(file_path): + try: + os.remove(file_path) + logger.debug(f"Deleted cache file: {file_path}") + except OSError as e: + logger.error(f"Error deleting cache file {file_path}: {e}") + + def clear( + self, clear_all=False, before_datetime: Union[datetime, date, str, int, float, None] = None + ): + """Deletes all cache files or those expiring before `before_datetime`. + + Args: + clear_all (bool, optional): Delete all cache files. Default is False. + before_datetime (Union[datetime, date, str, int, float, None], optional): The + threshold date. Cache files that are only valid before this date will be deleted. + The default datetime is beginning of today. + + Raises: + OSError: If there's an error during file deletion. + """ + delete_keys = [] # List of keys to delete, prevent deleting when traversing the store + clear_timestamp = None + + with self._store_lock: # Synchronize access to _store + for cache_file_key, cache_item in self._store.items(): + cache_file = cache_item[0] + + # Some weired logic to prevent calling to_datetime on clear_all. + # Clear_all may be set on __del__. At this time some info for to_datetime will + # not be available anymore. + clear_file = clear_all + if not clear_all: + if clear_timestamp is None: + before_datetime = to_datetime(before_datetime, to_maxtime=False) + # Convert the threshold date to a timestamp (seconds since epoch) + clear_timestamp = to_datetime(before_datetime).timestamp() + cache_file_timestamp = to_datetime(cache_item[1]).timestamp() + if cache_file_timestamp < clear_timestamp: + clear_file = True + + if clear_file: + # We have to clear this cache file + delete_keys.append(cache_file_key) + + file_path = self._get_file_path(cache_file) + + if file_path is None: + # In memory file like object + logger.warning( + f"The cache file with key '{cache_file_key}' is an in memory " + f"file object. Will only delete store entry but not file." + ) + continue + + if not os.path.exists(file_path): + # Already deleted + logger.warning(f"The cache file '{file_path}' was already deleted.") + continue + + # Finally remove the file + try: + os.remove(file_path) + logger.debug(f"Deleted cache file: {file_path}") + except OSError as e: + logger.error(f"Error deleting cache file {file_path}: {e}") + + for delete_key in delete_keys: + del self._store[delete_key] + + +def cache_in_file( + ignore_params: List[str] = [], + until_date: Union[datetime, date, str, int, float, None] = None, + until_datetime: Union[datetime, date, str, int, float, None] = None, + with_ttl: Union[timedelta, str, int, float, None] = None, + mode: str = "wb+", + delete: bool = False, + suffix: Optional[str] = None, +): + """Decorator to cache the output of a function into a temporary file. + + The decorator caches function output to a cache file based on its inputs as key to identify the + cache file. Ignore parameters are used to avoid key generation on non-deterministic inputs, such + as time values. We can also ignore parameters that are slow to serialize/constant across runs, + such as large objects. + + The cache file is created using `CacheFileStore` and stored with the generated key. + If the file exists in the cache and has not expired, it is returned instead of recomputing the + result. + + The decorator scans the arguments of the decorated function for a 'until_date' or + 'until_datetime` or `with_ttl` parameter. The value of this parameter will be used instead of + the one given in the decorator if available. + + Content of cache files without a suffix are transparently pickled to save file space. + + Args: + ignore_params (List[str], optional): + until_date (Union[datetime, date, str, int, float, None], optional): The date + until the cache file is valid. Time of day is set to maximum time (23:59:59). + until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime + until the cache file is valid. Time of day is set to maximum time (23:59:59) if not + provided. + with_ttl (Union[timedelta, str, int, float, None], optional): The time to live that + the cache file is valid. Time starts now. + mode (str, optional): The mode in which the file will be opened. Defaults to 'wb+'. + delete (bool, optional): Whether the cache file will be deleted after being closed. + Defaults to False. + suffix (str, optional): A suffix for the cache file, such as an extension (e.g., '.txt'). + Defaults to None. + + Returns: + callable: A decorated function that caches its result in a file. + + Example: + >>> @cache_in_file(suffix = '.txt') + >>> def expensive_computation(until_date = None): + >>> # Perform some expensive computation + >>> return 'Some large result' + >>> + >>> result = expensive_computation(until_date = date.today()) + """ + + def decorator(func): + nonlocal ignore_params, until_date, until_datetime, with_ttl, mode, delete, suffix + func_source_code = inspect.getsource(func) + + def wrapper(*args, **kwargs): + nonlocal ignore_params, until_date, until_datetime, with_ttl, mode, delete, suffix + # Convert args to a dictionary based on the function's signature + args_names = func.__code__.co_varnames[: func.__code__.co_argcount] + args_dict = dict(zip(args_names, args)) + + # Search for caching parameters of function and remove + for param in ["until_datetime", "with_ttl", "until_date"]: + if param in kwargs: + if param == "until_datetime": + until_datetime = kwargs[param] + until_date = None + with_ttl = None + elif param == "with_ttl": + until_datetime = None + until_date = None + with_ttl = kwargs[param] + elif param == "until_date": + until_datetime = None + until_date = kwargs[param] + with_ttl = None + kwargs.pop("until_datetime", None) + kwargs.pop("until_date", None) + kwargs.pop("with_ttl", None) + break + + # Remove ignored params + kwargs_clone = kwargs.copy() + for param in ignore_params: + args_dict.pop(param, None) + kwargs_clone.pop(param, None) + + # Create key based on argument names, argument values, and function source code + key = str(args_dict) + str(kwargs_clone) + str(func_source_code) + + result = None + cache_file = CacheFileStore().get(key) + if cache_file is not None: + # cache file is available + try: + logger.debug("Used cache file for function: " + func.__name__) + cache_file.seek(0) + if "b" in mode: + result = pickle.load(cache_file) + else: + result = cache_file.read() + except Exception: + logger.info("Read failed") + else: + # Otherwise, call the function and save its result to the cache + logger.debug("Created cache file for function: " + func.__name__) + cache_file = CacheFileStore().create( + key, + mode=mode, + delete=delete, + suffix=suffix, + until_datetime=until_datetime, + until_date=until_date, + with_ttl=with_ttl, + ) + result = func(*args, **kwargs) + try: + if "b" in mode: + pickle.dump(result, cache_file) + else: + cache_file.write(result) + except Exception as e: + logger.info(f"Write failed: {e}") + return result + + return wrapper + + return decorator diff --git a/src/akkudoktoreos/datetimeutil.py b/src/akkudoktoreos/datetimeutil.py new file mode 100644 index 00000000..6d87d6c7 --- /dev/null +++ b/src/akkudoktoreos/datetimeutil.py @@ -0,0 +1,221 @@ +"""Utility functions for date-time conversion tasks. + +Functions: +---------- +- to_datetime: Converts various date or time inputs to a timezone-aware or naive `datetime` + object or formatted string. +- to_timedelta: Converts various time delta inputs to a `timedelta`object. + +Example usage: +-------------- + + # Date-time conversion + >>> date_str = "2024-10-15" + >>> date_obj = to_datetime(date_str) + >>> print(date_obj) # Output: datetime object for '2024-10-15' + + # Time delta conversion + >>> to_timedelta("2 days 5 hours") +""" + +import re +from datetime import date, datetime, time, timedelta, timezone +from typing import Optional, Union +from zoneinfo import ZoneInfo + + +def to_datetime( + date_input: Union[datetime, date, str, int, float, None], + as_string: Optional[str] = None, + to_timezone: Optional[Union[timezone, str]] = None, + to_naiv: Optional[bool] = None, + to_maxtime: Optional[bool] = None, +): + """Converts a date input to a datetime object or a formatted string with timezone support. + + Args: + date_input (Union[datetime, date, str, int, float, None]): The date input to convert. + Accepts a date string, a datetime object, a date object or a Unix timestamp. + as_string (Optional[str]): If format string is given return datetime as a string. + Otherwise, return datetime object. The default. + to_timezone (Optional[Union[timezone, str]]): + Optional timezone object or name (e.g., 'UTC', 'Europe/Berlin'). + If provided, the datetime will be converted to this timezone. + If not provided, the datetime will be converted to the local timezone. + to_naiv (Optional[bool]): + If True, remove timezone info from datetime after conversion. The default. + If False, keep timezone info after conversion. + to_maxtime (Optional[bool]): + If True, convert to maximum time of no time is given. The default. + If False, convert to minimum time if no time is given. + + Example: + to_datetime("2027-12-12 24:13:12", as_string = "%Y-%m-%dT%H:%M:%S.%f%z") + + Returns: + datetime or str: Converted date as a datetime object or a formatted string with timezone. + + Raises: + ValueError: If the date input is not a valid type or format. + """ + if isinstance(date_input, datetime): + dt_object = date_input + elif isinstance(date_input, date): + # Convert date object to datetime object + if to_maxtime is None or to_maxtime: + dt_object = datetime.combine(date_input, time.max) + else: + dt_object = datetime.combine(date_input, time.max) + elif isinstance(date_input, (int, float)): + # Convert timestamp to datetime object + dt_object = datetime.fromtimestamp(date_input, tz=timezone.utc) + elif isinstance(date_input, str): + # Convert string to datetime object + try: + # Try ISO format + dt_object = datetime.fromisoformat(date_input[:-1]) # Remove 'Z' for UTC + except ValueError as e: + formats = [ + "%Y-%m-%d", # Format: 2024-10-13 + "%d/%m/%Y", # Format: 13/10/2024 + "%m-%d-%Y", # Format: 10-13-2024 + "%Y.%m.%d", # Format: 2024.10.13 + "%d %b %Y", # Format: 13 Oct 2024 + "%d %B %Y", # Format: 13 October 2024 + "%Y-%m-%d %H:%M:%S%z", # Format with timezone: 2024-10-13 15:30:00+0000 + "%Y-%m-%d %H:%M:%S %Z", # Format with timezone: 2024-10-13 15:30:00 UTC + "%Y-%m-%dT%H:%M:%S.%f%z", # Format with timezone: 2024-10-13T15:30:00.000+0000 + ] + + for fmt in formats: + try: + dt_object = datetime.strptime(date_input, fmt) + break + except ValueError as e: + dt_object = None + continue + if dt_object is None: + raise ValueError(f"Date string {date_input} does not match any known formats.") + elif date_input is None: + if to_maxtime is None or to_maxtime: + dt_object = datetime.combine(date.today(), time.max) + else: + dt_object = datetime.combine(date.today(), time.min) + else: + raise ValueError(f"Unsupported date input type: {type(date_input)}") + + # Get local timezone + local_date = datetime.now().astimezone() + local_tz_name = local_date.tzname() + local_utc_offset = local_date.utcoffset() + local_timezone = timezone(local_utc_offset, local_tz_name) + + # Get target timezone + if to_timezone: + if isinstance(to_timezone, timezone): + target_timezone = to_timezone + elif isinstance(to_timezone, str): + try: + target_timezone = ZoneInfo(to_timezone) + except Exception as e: + raise ValueError(f"Invalid timezone: {to_timezone}") from e + else: + raise ValueError(f"Invalid timezone: {to_timezone}") + + # Adjust/Add timezone information + if dt_object.tzinfo is None or dt_object.tzinfo.utcoffset(dt_object) is None: + # datetime object is naive (not timezone aware) + # Add timezone + if to_timezone is None: + # Add local timezone + dt_object = dt_object.replace(tzinfo=local_timezone) + else: + # Set to target timezone + dt_object = dt_object.replace(tzinfo=target_timezone) + elif to_timezone: + # Localize the datetime object to given target timezone + dt_object = dt_object.astimezone(target_timezone) + else: + # Localize the datetime object to local timezone + dt_object = dt_object.astimezone(local_timezone) + + if to_naiv is None or to_naiv: + # naiv not given defaults to True + # Remove timezone info to make the datetime naiv + dt_object = dt_object.replace(tzinfo=None) + + if as_string: + # Return formatted string as defined by as_string + return dt_object.strftime(as_string) + else: + return dt_object + + +def to_timedelta(input_value): + """Converts various input types into a timedelta object. + + Args: + input_value (Union[timedelta, str, int, float, tuple, list]): Input to be converted + timedelta. + - str: A string like "2 days", "5 hours", "30 minutes", or a combination. + - int/float: Number representing seconds. + - tuple/list: A tuple or list in the format (days, hours, minutes, seconds). + + Returns: + timedelta: A timedelta object corresponding to the input value. + + Raises: + ValueError: If the input format is not supported. + + Examples: + >>> to_timedelta("2 days 5 hours") + datetime.timedelta(days=2, seconds=18000) + + >>> to_timedelta(3600) + datetime.timedelta(seconds=3600) + + >>> to_timedelta((1, 2, 30, 15)) + datetime.timedelta(days=1, seconds=90315) + """ + if isinstance(input_value, timedelta): + return input_value + + if isinstance(input_value, (int, float)): + # Handle integers or floats as seconds + return timedelta(seconds=input_value) + + elif isinstance(input_value, (tuple, list)): + # Handle tuple or list: (days, hours, minutes, seconds) + if len(input_value) == 4: + days, hours, minutes, seconds = input_value + return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + else: + raise ValueError(f"Expected a tuple or list of length 4, got {len(input_value)}") + + elif isinstance(input_value, str): + # Handle strings like "2 days 5 hours 30 minutes" + total_seconds = 0 + time_units = { + "day": 86400, # 24 * 60 * 60 + "hour": 3600, + "minute": 60, + "second": 1, + } + + # Regular expression to match time components like '2 days', '5 hours', etc. + matches = re.findall(r"(\d+)\s*(days?|hours?|minutes?|seconds?)", input_value) + + if not matches: + raise ValueError(f"Invalid time string format: {input_value}") + + for value, unit in matches: + unit = unit.lower().rstrip("s") # Normalize unit + if unit in time_units: + total_seconds += int(value) * time_units[unit] + else: + raise ValueError(f"Unsupported time unit: {unit}") + + return timedelta(seconds=total_seconds) + + else: + raise ValueError(f"Unsupported input type: {type(input_value)}") diff --git a/src/akkudoktoreos/logutil.py b/src/akkudoktoreos/logutil.py new file mode 100644 index 00000000..a2b471c0 --- /dev/null +++ b/src/akkudoktoreos/logutil.py @@ -0,0 +1,95 @@ +"""Utility functions for handling logging tasks. + +Functions: +---------- +- get_logger: Creates and configures a logger with console and optional rotating file logging. + +Example usage: +-------------- + # Logger setup + >>> logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG") + >>> logger.info("Logging initialized.") + +Notes: +------ +- The logger supports rotating log files to prevent excessive log file size. +""" + +import logging +import os +from logging.handlers import RotatingFileHandler +from typing import Optional + + +def get_logger( + name: str, + log_file: Optional[str] = None, + logging_level: Optional[str] = "INFO", + max_bytes: int = 5000000, + backup_count: int = 5, +) -> logging.Logger: + """Creates and configures a logger with a given name. + + The logger supports logging to both the console and an optional log file. File logging is + handled by a rotating file handler to prevent excessive log file size. + + Args: + name (str): The name of the logger, typically `__name__` from the calling module. + log_file (Optional[str]): Path to the log file for file logging. If None, no file logging is done. + logging_level (Optional[str]): Logging level (e.g., "INFO", "DEBUG"). Defaults to "INFO". + max_bytes (int): Maximum size in bytes for log file before rotation. Defaults to 5 MB. + backup_count (int): Number of backup log files to keep. Defaults to 5. + + Returns: + logging.Logger: Configured logger instance. + + Example: + logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG") + logger.info("Application started") + """ + # Create a logger with the specified name + logger = logging.getLogger(name) + logger.propagate = True + if logging_level == "DEBUG": + level = logging.DEBUG + elif logging_level == "INFO": + level = logging.INFO + elif logging_level == "WARNING": + level = logging.WARNING + elif logging_level == "ERROR": + level = logging.ERROR + else: + level = logging.DEBUG + logger.setLevel(level) + + # The log message format + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + + # Prevent loggers from being added multiple times + # There may already be a logger from pytest + if not logger.handlers: + # Create a console handler with a standard output stream + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + + # Add the console handler to the logger + logger.addHandler(console_handler) + + if log_file and len(logger.handlers) < 2: # We assume a console logger to be the first logger + # If a log file path is specified, create a rotating file handler + + # Ensure the log directory exists + log_dir = os.path.dirname(log_file) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir) + + # Create a rotating file handler + file_handler = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + + # Add the file handler to the logger + logger.addHandler(file_handler) + + return logger diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..d28aa4f0 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,200 @@ +"""Test Module for Utilities Module.""" + +import io +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from akkudoktoreos.util import CacheFileStore, to_datetime + +# ----------------------------- +# to_datetime +# ----------------------------- + + +def test_to_datetime(): + """Test date conversion as needed by PV forecast data.""" + date_time = to_datetime( + "2024-10-07T10:20:30.000+02:00", to_timezone="Europe/Berlin", to_naiv=False + ) + expected_date_time = datetime(2024, 10, 7, 10, 20, 30, 0, tzinfo=ZoneInfo("Europe/Berlin")) + assert date_time == expected_date_time + + date_time = to_datetime( + "2024-10-07T10:20:30.000+02:00", to_timezone="Europe/Berlin", to_naiv=True + ) + expected_date_time = datetime(2024, 10, 7, 10, 20, 30, 0) + assert date_time == expected_date_time + + date_time = to_datetime("2024-10-07", to_timezone="Europe/Berlin", to_naiv=False) + expected_date_time = datetime(2024, 10, 7, 0, 0, 0, 0, tzinfo=ZoneInfo("Europe/Berlin")) + assert date_time == expected_date_time + + date_time = to_datetime("2024-10-07", to_timezone="Europe/Berlin", to_naiv=True) + expected_date_time = datetime(2024, 10, 7, 0, 0, 0, 0) + assert date_time == expected_date_time + + +# ----------------------------- +# CacheFileStore +# ----------------------------- + + +@pytest.fixture +def cache_store(): + """A pytest fixture that creates a new CacheFileStore instance for testing.""" + return CacheFileStore() + + +def test_generate_cache_file_key(cache_store): + """Test cache file key generation based on URL and date.""" + key = "http://example.com" + key_date = "2024-10-01" + cache_file_key, cache_file_key_date = cache_store._generate_cache_file_key(key, key_date) + expected_file_key = "0f6b92d1be8ef1e6a0b440de2963a7b847b54a8af267f2fab7f8756f30d733ac" + assert cache_file_key == expected_file_key + assert cache_file_key_date == key_date + + +def test_get_file_path(cache_store): + """Test get file path from cache file object.""" + cache_file = cache_store.create("test_file", mode="w+", suffix=".txt") + file_path = cache_store._get_file_path(cache_file) + + assert file_path is not None + + +def test_create_cache_file(cache_store): + """Test the creation of a cache file and ensure it is stored correctly.""" + # Create a cache file for today's date + cache_file = cache_store.create("test_file", mode="w+", suffix=".txt") + + # Check that the file exists in the store and is a file-like object + assert cache_file is not None + assert hasattr(cache_file, "name") + assert cache_file.name.endswith(".txt") + + # Write some data to the file + cache_file.seek(0) + cache_file.write("Test data") + cache_file.seek(0) # Reset file pointer + assert cache_file.read() == "Test data" + + +def test_get_cache_file(cache_store): + """Test retrieving an existing cache file by key.""" + # Create a cache file and write data to it + cache_file = cache_store.create("test_file", mode="w+") + cache_file.seek(0) + cache_file.write("Test data") + cache_file.seek(0) + + # Retrieve the cache file and verify the data + retrieved_file = cache_store.get("test_file") + assert retrieved_file is not None + retrieved_file.seek(0) + assert retrieved_file.read() == "Test data" + + +def test_set_custom_file_object(cache_store): + """Test setting a custom file-like object (BytesIO or StringIO) in the store.""" + # Create a BytesIO object and set it into the cache + file_obj = io.BytesIO(b"Binary data") + cache_store.set("binary_file", file_obj) + + # Retrieve the file from the store + retrieved_file = cache_store.get("binary_file") + assert isinstance(retrieved_file, io.BytesIO) + retrieved_file.seek(0) + assert retrieved_file.read() == b"Binary data" + + +def test_delete_cache_file(cache_store): + """Test deleting a cache file from the store.""" + # Create multiple cache files + cache_file1 = cache_store.create("file1") + assert hasattr(cache_file1, "name") + cache_file2 = cache_store.create("file2") + assert hasattr(cache_file2, "name") + + # Ensure the files are in the store + assert cache_store.get("file1") is cache_file1 + assert cache_store.get("file2") is cache_file2 + + # Delete cache files + cache_store.delete("file1") + cache_store.delete("file2") + + # Ensure the store is empty + assert cache_store.get("file1") is None + assert cache_store.get("file2") is None + + +def test_clear_cache_files(cache_store): + """Test clearing all cache files from the store.""" + # Create multiple cache files + cache_file1 = cache_store.create("file1") + assert hasattr(cache_file1, "name") + cache_file2 = cache_store.create("file2") + assert hasattr(cache_file2, "name") + + # Ensure the files are in the store + assert cache_store.get("file1") is cache_file1 + assert cache_store.get("file2") is cache_file2 + + # Clear all cache files + cache_store.clear() + + # Ensure the store is empty + assert cache_store.get("file1") is None + assert cache_store.get("file2") is None + + +def test_cache_file_with_date(cache_store): + """Test creating and retrieving cache files with a specific date.""" + # Use a specific date for cache file creation + specific_date = datetime(2023, 10, 10) + cache_file = cache_store.create("dated_file", key_date=specific_date) + + # Write data to the cache file + cache_file.write("Dated data") + cache_file.seek(0) + + # Retrieve the cache file with the specific date + retrieved_file = cache_store.get("dated_file", key_date=specific_date) + assert retrieved_file is not None + retrieved_file.seek(0) + assert retrieved_file.read() == "Dated data" + + +def test_recreate_existing_cache_file(cache_store): + """Test creating a cache file with an existing key does not overwrite the existing file.""" + # Create a cache file + cache_file = cache_store.create("test_file", mode="w+") + cache_file.write("Original data") + cache_file.seek(0) + + # Attempt to recreate the same file (should return the existing one) + new_file = cache_store.create("test_file") + assert new_file is cache_file # Should be the same object + new_file.seek(0) + assert new_file.read() == "Original data" # Data should be preserved + + # Assure cache file store is a singleton + cache_store2 = CacheFileStore() + new_file = cache_store2.get("test_file") + assert new_file is cache_file # Should be the same object + + +def test_cache_store_is_singleton(cache_store): + """Test re-creating a cache store provides the same store.""" + # Create a cache file + cache_file = cache_store.create("test_file", mode="w+") + cache_file.write("Original data") + cache_file.seek(0) + + # Assure cache file store is a singleton + cache_store2 = CacheFileStore() + new_file = cache_store2.get("test_file") + assert new_file is cache_file # Should be the same object From d38b24aeef510960bda1785aa598d8009d13364d Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Sat, 12 Oct 2024 07:08:25 +0200 Subject: [PATCH 19/21] Improve testability of PVForecast Improvements for testing of PVForecast - Use common utility functions to allow for general testing at one spot. - to_datetime - CacheFileStore - Use logging instead of print to easily capture in testing. - Add validation of the json schema for Akkudoktor PV forecast data. - Allow to create an empty PVForecast instance as base instance for testing. - Make process_data() complete for filling a PVForecast instance for testing. - Normalize forecast datetime to timezone of system given in loaded data. - Do not print report but provide report for test checks. - Get rid of cache file path using the CachFileStore to automate cache file usage. - Improved module documentation. Signed-off-by: Bobby Noelte --- requirements.txt | 1 + src/akkudoktoreos/class_pv_forecast.py | 518 ++++++++++++++++++------- 2 files changed, 376 insertions(+), 143 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5ffc9753..69881a3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ scikit-learn==1.5.2 deap==1.4.1 requests==2.32.3 pandas==2.2.3 +pydantic==2.9.2 diff --git a/src/akkudoktoreos/class_pv_forecast.py b/src/akkudoktoreos/class_pv_forecast.py index 9ad2d6b3..b1b562bf 100644 --- a/src/akkudoktoreos/class_pv_forecast.py +++ b/src/akkudoktoreos/class_pv_forecast.py @@ -5,7 +5,7 @@ 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: +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 @@ -13,8 +13,8 @@ and update the forecast data, convert it to a DataFrame, and output key metrics like AC power. -Example usage: - # Initialize PVForecast class with a URL +Example: + # Initialize PVForecast class with an URL forecast = PVForecast( prediction_hours=24, url="https://api.akkudoktor.net/forecast?lat=50.8588&lon=7.3747..." @@ -31,21 +31,81 @@ print(df) Attributes: - cache_dir (str): The directory where cached data is stored. Defaults to 'cache'. prediction_hours (int): Number of forecast hours. Defaults to 48. """ -import hashlib import json -import os -from datetime import datetime -from pprint import pprint +from datetime import date, datetime +from typing import List, Optional, Union import numpy as np import pandas as pd import requests -from dateutil import parser -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError + +from akkudoktoreos.cachefilestore import cache_in_file +from akkudoktoreos.datetimeutil import to_datetime +from akkudoktoreos.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): @@ -67,12 +127,12 @@ class ForecastData: def __init__( self, - date_time, - dc_power, - ac_power, - windspeed_10m=None, - temperature=None, - ac_power_measurement=None, + 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. @@ -91,7 +151,7 @@ def __init__( self.temperature = temperature self.ac_power_measurement = ac_power_measurement - def get_date_time(self): + def get_date_time(self) -> datetime: """Returns the forecast date and time. Returns: @@ -99,7 +159,7 @@ def get_date_time(self): """ return self.date_time - def get_dc_power(self): + def get_dc_power(self) -> float: """Returns the DC power. Returns: @@ -107,7 +167,7 @@ def get_dc_power(self): """ return self.dc_power - def ac_power_measurement(self): + def ac_power_measurement(self) -> float: """Returns the measured AC power. It returns the measured AC power if available; otherwise None. @@ -117,7 +177,7 @@ def ac_power_measurement(self): """ return self.ac_power_measurement - def get_ac_power(self): + def get_ac_power(self) -> float: """Returns the AC power. If a measured value is available, it returns the measured AC power; @@ -131,7 +191,7 @@ def get_ac_power(self): else: return self.ac_power - def get_windspeed_10m(self): + def get_windspeed_10m(self) -> float: """Returns the wind speed at 10 meters altitude. Returns: @@ -139,7 +199,7 @@ def get_windspeed_10m(self): """ return self.windspeed_10m - def get_temperature(self): + def get_temperature(self) -> float: """Returns the temperature. Returns: @@ -149,50 +209,77 @@ def get_temperature(self): class PVForecast: - """Manages PV power forecasts and weather data. + """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 of the forecast. - forecast_data (list): List of ForecastData objects. - cache_dir (str): Directory for cached data. - prediction_hours (int): Number of hours for which the forecast is made. - current_measurement (float): Current AC power measurement. + 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, filepath=None, url=None, cache_dir="cache", prediction_hours=48): - """Initializes the PVForecast instance. + 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. - Loads data either from a file or from a URL. + 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. - Args: - filepath (str, optional): Path to the JSON file with forecast data. Defaults to None. - url (str, optional): URL to the API providing forecast data. Defaults to None. - cache_dir (str, optional): Directory for cache data. Defaults to "cache". - prediction_hours (int, optional): Number of hours to forecast. Defaults to 48. + Use `process_data()` to fill an empty forecast later on. - Raises: - ValueError: If the forecasted data is less than `prediction_hours`. + 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.cache_dir = cache_dir - self.prediction_hours = prediction_hours self.current_measurement = None - - if not os.path.exists(self.cache_dir): - os.makedirs(self.cache_dir) - if filepath: - self.load_data_from_file(filepath) - elif url: - self.load_data_with_caching(url) - - 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 were predicted." + self.data = data + self.filepath = filepath + self.url = url + if forecast_start: + self._forecast_start = to_datetime(forecast_start, 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=None, ac_power_measurement=None) -> bool: + 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: @@ -203,114 +290,221 @@ def update_ac_power_measurement(self, date_time=None, ac_power_measurement=None) bool: True if a matching timestamp was found, False otherwise. """ found = False - input_date_hour = date_time.replace(minute=0, second=0, microsecond=0) + 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 = parser.parse(forecast.date_time).replace( + forecast_date_hour = to_datetime(forecast.date_time).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): - """Processes JSON data and stores the forecasts. + 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 (dict): JSON data containing forecast values. + 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=50.8588&lon=7.3747&" + "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, + ) """ - self.meta = data.get("meta", {}) - all_values = data.get("values", []) - - for i in range(len(all_values[0])): # Annahme, dass alle Listen gleich lang sind - sum_dc_power = sum(values[i]["dcPower"] for values in all_values) - sum_ac_power = sum(values[i]["power"] for values in all_values) - - # 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 = datetime.strptime(original_datetime, "%Y-%m-%dT%H:%M:%S.%f%z") - dt = dt.replace(tzinfo=None) - # 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()}") - - 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"), + # 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) - 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." + ) - def load_data_from_file(self, filepath): + # 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) - self.process_data(data) + return data - def load_data_from_url(self, url): + def load_data_from_url(self, url: str) -> dict: """Loads forecast data from a URL. + Example: + https://api.akkudoktor.net/forecast?lat=50.8588&lon=7.3747&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() - pprint(data) - self.process_data(data) else: - print(f"Failed to load data from {url}. Status Code: {response.status_code}") - self.load_data_from_url(url) + data = f"Failed to load data from `{url}`. Status Code: {response.status_code}" + logger.error(data) + return data - def load_data_with_caching(self, url): + @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. - """ - date = datetime.now().strftime("%Y-%m-%d") - - cache_file = os.path.join(self.cache_dir, self.generate_cache_filename(url, date)) - if os.path.exists(cache_file): - with open(cache_file, "r") as file: - data = json.load(file) - print("Loading data from cache.") - else: - response = requests.get(url) - if response.status_code == 200: - data = response.json() - with open(cache_file, "w") as file: - json.dump(data, file) - print("Data fetched from URL and cached.") - else: - print(f"Failed to load data from {url}. Status Code: {response.status_code}") - return - self.process_data(data) - - def generate_cache_filename(self, url, date): - """Generates a cache filename based on the URL and date. - - Args: - url (str): URL of the API. - date (str): Date in the format YYYY-MM-DD. Returns: - str: Generated cache filename. + data (dict): JSON data containing forecast values. """ - cache_key = hashlib.sha256(f"{url}{date}".encode("utf-8")).hexdigest() - return f"cache_{cache_key}.json" + 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. @@ -320,20 +514,24 @@ def get_forecast_data(self): """ return self.forecast_data - def get_temperature_forecast_for_date(self, input_date_str): + 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 (str): Date in the format YYYY-MM-DD. + input_date (str): Date Returns: np.array: Array of temperature forecasts. """ - input_date = datetime.strptime(input_date_str, "%Y-%m-%d") + 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).date() daily_forecast_obj = [ - data - for data in self.forecast_data - if parser.parse(data.get_date_time()).date() == input_date.date() + data for data in self.forecast_data if data.get_date_time().date() == input_date ] daily_forecast = [] for d in daily_forecast_obj: @@ -341,7 +539,11 @@ def get_temperature_forecast_for_date(self, input_date_str): return np.array(daily_forecast) - def get_pv_forecast_for_date_range(self, start_date_str, end_date_str): + 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: @@ -351,32 +553,44 @@ def get_pv_forecast_for_date_range(self, start_date_str, end_date_str): Returns: pd.DataFrame: DataFrame containing the forecast data. """ - start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() - end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() + 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).date() + end_date = to_datetime(end_date, to_timezone=self._tz_name).date() date_range_forecast = [] for data in self.forecast_data: - data_date = data.get_date_time().date() # parser.parse(data.get_date_time()).date() + 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()) + # 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_str, end_date_str): + 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_str (str): Start date in the format YYYY-MM-DD. - end_date_str (str): End date in the format YYYY-MM-DD. + 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. """ - start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() - end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() + 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).date() + end_date = to_datetime(end_date, to_timezone=self._tz_name).date() date_range_forecast = [] for data in self.forecast_data: @@ -409,18 +623,36 @@ def get_forecast_dataframe(self): df = pd.DataFrame(data) return df - def print_ac_power_and_measurement(self): - """Prints the DC power, AC power, and AC power measurement for each forecast hour. + def get_forecast_start(self) -> datetime: + """Return the start of the forecast data in local timezone. - For each forecast entry, it prints the time, DC power, forecasted AC power, - measured AC power (if available), and the value returned by the `get_ac_power` method. + Returns: + forecast_start (datetime | None): The start datetime or None if no data available. """ + if not self._forecast_start: + return None + return to_datetime( + to_datetime(self._forecast_start, to_timezone=self._tz_name), 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 - print( + rep += ( f"Zeit: {date_time}, DC: {forecast.dc_power}, AC: {forecast.ac_power}, " - "Messwert: {forecast.ac_power_measurement}, AC GET: {forecast.get_ac_power()}" + f"Messwert: {forecast.ac_power_measurement}, AC GET: {forecast.get_ac_power()}" + "\n" ) + return rep # Example of how to use the PVForecast class @@ -441,4 +673,4 @@ def print_ac_power_and_measurement(self): "hourly=relativehumidity_2m%2Cwindspeed_10m", ) forecast.update_ac_power_measurement(date_time=datetime.now(), ac_power_measurement=1000) - forecast.print_ac_power_and_measurement() + print(forecast.report_ac_power_and_measurement()) From 7ad1eea21f9cd8377a08e194862ab46bb127a76f Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Sat, 12 Oct 2024 07:10:52 +0200 Subject: [PATCH 20/21] Add test for PVForecast and newly extracted utility modules. - Add test for PVForecast - Add test for CacheFileStore in the new cachefilestore module - Add test for to_datetime in the new datetimeutil module - Add test for get_logger in the new logutil module Signed-off-by: Bobby Noelte --- tests/conftest.py | 42 +++ tests/test_cachefilestore.py | 325 ++++++++++++++++++++++++ tests/test_datetimeutil.py | 67 +++++ tests/test_logutil.py | 82 ++++++ tests/test_pv_forecast.py | 282 ++++++++++++++++++++ tests/test_util.py | 200 --------------- tests/testdata/pv_forecast_input_1.json | 1 + tests/testdata/pv_forecast_result_1.txt | 288 +++++++++++++++++++++ 8 files changed, 1087 insertions(+), 200 deletions(-) create mode 100644 tests/test_cachefilestore.py create mode 100644 tests/test_datetimeutil.py create mode 100644 tests/test_logutil.py create mode 100644 tests/test_pv_forecast.py delete mode 100644 tests/test_util.py create mode 100644 tests/testdata/pv_forecast_input_1.json create mode 100644 tests/testdata/pv_forecast_result_1.txt diff --git a/tests/conftest.py b/tests/conftest.py index e913735a..44b19e54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,29 @@ +import logging import os import subprocess import sys +import time import pytest from xprocess import ProcessStarter +@pytest.fixture(autouse=True) +def disable_debug_logging(): + # Temporarily set logging level higher than DEBUG + logging.disable(logging.DEBUG) + yield + # Re-enable logging back to its original state after the test + logging.disable(logging.NOTSET) + + @pytest.fixture def server(xprocess): + """Fixture to start the server. + + Provides URL of the server. + """ + class Starter(ProcessStarter): # assure server to be installed try: @@ -51,3 +67,29 @@ class Starter(ProcessStarter): # clean up whole process tree afterwards xprocess.getinfo("akkudoktoreosserver").terminate() + + +@pytest.fixture +def other_timezone(): + """Fixture to temporarily change the timezone. + + Restores the original timezone after the test. + """ + original_tz = os.environ.get("TZ", None) + + other_tz = "Atlantic/Canary" + if original_tz == other_tz: + other_tz = "Asia/Singapore" + + # Change the timezone to another + os.environ["TZ"] = other_tz + time.tzset() # For Unix/Linux to apply the timezone change + + yield os.environ["TZ"] # Yield control back to the test case + + # Restore the original timezone after the test + if original_tz: + os.environ["TZ"] = original_tz + else: + del os.environ["TZ"] + time.tzset() # Re-apply the original timezone diff --git a/tests/test_cachefilestore.py b/tests/test_cachefilestore.py new file mode 100644 index 00000000..cb060dc7 --- /dev/null +++ b/tests/test_cachefilestore.py @@ -0,0 +1,325 @@ +"""Test Module for CacheFileStore Module.""" + +import io +import pickle +from datetime import date, datetime, time, timedelta +from time import sleep + +import pytest + +from akkudoktoreos.cachefilestore import CacheFileStore, cache_in_file +from akkudoktoreos.datetimeutil import to_datetime + +# ----------------------------- +# CacheFileStore +# ----------------------------- + + +@pytest.fixture +def cache_store(): + """A pytest fixture that creates a new CacheFileStore instance for testing.""" + return CacheFileStore() + + +def test_generate_cache_file_key(cache_store): + """Test cache file key generation based on URL and date.""" + key = "http://example.com" + until_dt = to_datetime("2024-10-01").date() + cache_file_key, cache_file_until_dt = cache_store._generate_cache_file_key(key, until_dt) + assert cache_file_key is not None + assert cache_file_until_dt == until_dt + + # Provide no until date - assure today EOD is used. + until_dt = datetime.combine(date.today(), time.max) + cache_file_key, cache_file_until_dt = cache_store._generate_cache_file_key(key, None) + assert cache_file_until_dt == until_dt + cache_file_key1, cache_file_until_dt1 = cache_store._generate_cache_file_key(key, until_dt) + assert cache_file_key == cache_file_key1 + assert cache_file_until_dt == until_dt + + +def test_get_file_path(cache_store): + """Test get file path from cache file object.""" + cache_file = cache_store.create("test_file", mode="w+", suffix=".txt") + file_path = cache_store._get_file_path(cache_file) + + assert file_path is not None + + +def test_create_cache_file(cache_store): + """Test the creation of a cache file and ensure it is stored correctly.""" + # Create a cache file for today's date + cache_file = cache_store.create("test_file", mode="w+", suffix=".txt") + + # Check that the file exists in the store and is a file-like object + assert cache_file is not None + assert hasattr(cache_file, "name") + assert cache_file.name.endswith(".txt") + + # Write some data to the file + cache_file.seek(0) + cache_file.write("Test data") + cache_file.seek(0) # Reset file pointer + assert cache_file.read() == "Test data" + + +def test_get_cache_file(cache_store): + """Test retrieving an existing cache file by key.""" + # Create a cache file and write data to it + cache_file = cache_store.create("test_file", mode="w+") + cache_file.seek(0) + cache_file.write("Test data") + cache_file.seek(0) + + # Retrieve the cache file and verify the data + retrieved_file = cache_store.get("test_file") + assert retrieved_file is not None + retrieved_file.seek(0) + assert retrieved_file.read() == "Test data" + + +def test_set_custom_file_object(cache_store): + """Test setting a custom file-like object (BytesIO or StringIO) in the store.""" + # Create a BytesIO object and set it into the cache + file_obj = io.BytesIO(b"Binary data") + cache_store.set("binary_file", file_obj) + + # Retrieve the file from the store + retrieved_file = cache_store.get("binary_file") + assert isinstance(retrieved_file, io.BytesIO) + retrieved_file.seek(0) + assert retrieved_file.read() == b"Binary data" + + +def test_delete_cache_file(cache_store): + """Test deleting a cache file from the store.""" + # Create multiple cache files + cache_file1 = cache_store.create("file1") + assert hasattr(cache_file1, "name") + cache_file2 = cache_store.create("file2") + assert hasattr(cache_file2, "name") + + # Ensure the files are in the store + assert cache_store.get("file1") is cache_file1 + assert cache_store.get("file2") is cache_file2 + + # Delete cache files + cache_store.delete("file1") + cache_store.delete("file2") + + # Ensure the store is empty + assert cache_store.get("file1") is None + assert cache_store.get("file2") is None + + +def test_clear_all_cache_files(cache_store): + """Test clearing all cache files from the store.""" + # Create multiple cache files + cache_file1 = cache_store.create("file1") + assert hasattr(cache_file1, "name") + cache_file2 = cache_store.create("file2") + assert hasattr(cache_file2, "name") + + # Ensure the files are in the store + assert cache_store.get("file1") is cache_file1 + assert cache_store.get("file2") is cache_file2 + + # Clear all cache files + cache_store.clear(clear_all=True) + + # Ensure the store is empty + assert cache_store.get("file1") is None + assert cache_store.get("file2") is None + + +def test_clear_cache_files_by_date(cache_store): + """Test clearing cache files from the store by date.""" + # Create multiple cache files + cache_file1 = cache_store.create("file1") + assert hasattr(cache_file1, "name") + cache_file2 = cache_store.create("file2") + assert hasattr(cache_file2, "name") + + # Ensure the files are in the store + assert cache_store.get("file1") is cache_file1 + assert cache_store.get("file2") is cache_file2 + + # Clear cache files that are older than today + cache_store.clear(before_datetime=datetime.combine(date.today(), time.min)) + + # Ensure the files are in the store + assert cache_store.get("file1") is cache_file1 + assert cache_store.get("file2") is cache_file2 + + # Clear cache files that are older than tomorrow + cache_store.clear(before_datetime=datetime.now() + timedelta(days=1)) + + # Ensure the store is empty + assert cache_store.get("file1") is None + assert cache_store.get("file2") is None + + +def test_cache_file_with_date(cache_store): + """Test creating and retrieving cache files with a specific date.""" + # Use a specific date for cache file creation + specific_date = datetime(2023, 10, 10) + cache_file = cache_store.create("dated_file", mode="w+", until_date=specific_date) + + # Write data to the cache file + cache_file.write("Dated data") + cache_file.seek(0) + + # Retrieve the cache file with the specific date + retrieved_file = cache_store.get("dated_file", until_date=specific_date) + assert retrieved_file is not None + retrieved_file.seek(0) + assert retrieved_file.read() == "Dated data" + + +def test_recreate_existing_cache_file(cache_store): + """Test creating a cache file with an existing key does not overwrite the existing file.""" + # Create a cache file + cache_file = cache_store.create("test_file", mode="w+") + cache_file.write("Original data") + cache_file.seek(0) + + # Attempt to recreate the same file (should return the existing one) + new_file = cache_store.create("test_file") + assert new_file is cache_file # Should be the same object + new_file.seek(0) + assert new_file.read() == "Original data" # Data should be preserved + + # Assure cache file store is a singleton + cache_store2 = CacheFileStore() + new_file = cache_store2.get("test_file") + assert new_file is cache_file # Should be the same object + + +def test_cache_store_is_singleton(cache_store): + """Test re-creating a cache store provides the same store.""" + # Create a cache file + cache_file = cache_store.create("test_file", mode="w+") + cache_file.write("Original data") + cache_file.seek(0) + + # Assure cache file store is a singleton + cache_store2 = CacheFileStore() + new_file = cache_store2.get("test_file") + assert new_file is cache_file # Should be the same object + + +def test_cache_in_file_decorator_caches_function_result(cache_store): + """Test that the cache_in_file decorator caches a function result.""" + # Clear store to assure it is empty + cache_store.clear(clear_all=True) + assert len(cache_store._store) == 0 + + # Define a simple function to decorate + @cache_in_file(mode="w+") + def my_function(until_date=None): + return "Some expensive computation result" + + # Call the decorated function (should store result in cache) + result = my_function(until_date=datetime.now() + timedelta(days=1)) + assert result == "Some expensive computation result" + + # Assert that the create method was called to store the result + assert len(cache_store._store) == 1 + + # Check if the result was written to the cache file + key = next(iter(cache_store._store)) + cache_file = cache_store._store[key][0] + assert cache_file is not None + + # Assert correct content was written to the file + cache_file.seek(0) # Move to the start of the file + assert cache_file.read() == "Some expensive computation result" + + +def test_cache_in_file_decorator_uses_cache(cache_store): + """Test that the cache_in_file decorator reuses cached file on subsequent calls.""" + # Clear store to assure it is empty + cache_store.clear(clear_all=True) + assert len(cache_store._store) == 0 + + # Define a simple function to decorate + @cache_in_file(mode="w+") + def my_function(until_date=None): + return "New result" + + # Call the decorated function (should store result in cache) + result = my_function(until_date=datetime.now() + timedelta(days=1)) + assert result == "New result" + + # Assert result was written to cache file + key = next(iter(cache_store._store)) + cache_file = cache_store._store[key][0] + assert cache_file is not None + cache_file.seek(0) # Move to the start of the file + assert cache_file.read() == result + + # Modify cache file + result2 = "Cached result" + cache_file.seek(0) + cache_file.write(result2) + + # Call the decorated function again (should get result from cache) + result = my_function(until_date=datetime.now() + timedelta(days=1)) + assert result == result2 + + +def test_cache_in_file_handles_ttl(cache_store): + """Test that the cache_infile decorator handles the with_ttl parameter.""" + # Clear store to assure it is empty + cache_store.clear(clear_all=True) + assert len(cache_store._store) == 0 + + # Define a simple function to decorate + @cache_in_file(mode="w+") + def my_function(): + return "New result" + + # Call the decorated function + result = my_function(with_ttl="1 second") + + # Overwrite cache file + key = next(iter(cache_store._store)) + cache_file = cache_store._store[key][0] + assert cache_file is not None + cache_file.seek(0) # Move to the start of the file + cache_file.write("Modified result") + cache_file.seek(0) # Move to the start of the file + assert cache_file.read() == "Modified result" + + result = my_function(with_ttl="1 second") + assert result == "Modified result" + + # Wait one second to let the cache time out + sleep(1) + + # Call again - cache should be timed out + result = my_function(with_ttl="1 second") + assert result == "New result" + + +def test_cache_in_file_handles_bytes_return(cache_store): + """Test that the cache_infile decorator handles bytes returned from the function.""" + # Clear store to assure it is empty + cache_store.clear(clear_all=True) + assert len(cache_store._store) == 0 + + # Define a function that returns bytes + @cache_in_file() + def my_function(until_date=None): + return b"Some binary data" + + # Call the decorated function + result = my_function(until_date=datetime.now() + timedelta(days=1)) + + # Check if the binary data was written to the cache file + key = next(iter(cache_store._store)) + cache_file = cache_store._store[key][0] + assert cache_file is not None + cache_file.seek(0) + result1 = pickle.load(cache_file) + assert result1 == result diff --git a/tests/test_datetimeutil.py b/tests/test_datetimeutil.py new file mode 100644 index 00000000..01a62d26 --- /dev/null +++ b/tests/test_datetimeutil.py @@ -0,0 +1,67 @@ +"""Test Module for datetimeutil Module.""" + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from akkudoktoreos.datetimeutil import to_datetime, to_timedelta + +# ----------------------------- +# to_datetime +# ----------------------------- + + +def test_to_datetime(): + """Test date conversion as needed by PV forecast data.""" + date_time = to_datetime( + "2024-10-07T10:20:30.000+02:00", to_timezone="Europe/Berlin", to_naiv=False + ) + expected_date_time = datetime(2024, 10, 7, 10, 20, 30, 0, tzinfo=ZoneInfo("Europe/Berlin")) + assert date_time == expected_date_time + + date_time = to_datetime( + "2024-10-07T10:20:30.000+02:00", to_timezone="Europe/Berlin", to_naiv=True + ) + expected_date_time = datetime(2024, 10, 7, 10, 20, 30, 0) + assert date_time == expected_date_time + + date_time = to_datetime("2024-10-07", to_timezone="Europe/Berlin", to_naiv=False) + expected_date_time = datetime(2024, 10, 7, 0, 0, 0, 0, tzinfo=ZoneInfo("Europe/Berlin")) + assert date_time == expected_date_time + + date_time = to_datetime("2024-10-07", to_timezone="Europe/Berlin", to_naiv=True) + expected_date_time = datetime(2024, 10, 7, 0, 0, 0, 0) + assert date_time == expected_date_time + + +# ----------------------------- +# to_timedelta +# ----------------------------- + + +# Test cases for valid timedelta inputs +@pytest.mark.parametrize( + "input_value, expected_output", + [ + # timedelta input + (timedelta(days=1), timedelta(days=1)), + # String input + ("2 days", timedelta(days=2)), + ("5 hours", timedelta(hours=5)), + ("30 minutes", timedelta(minutes=30)), + ("45 seconds", timedelta(seconds=45)), + ("1 day 2 hours 30 minutes 15 seconds", timedelta(days=1, hours=2, minutes=30, seconds=15)), + ("3 days 4 hours", timedelta(days=3, hours=4)), + # Integer/Float input + (3600, timedelta(seconds=3600)), # 1 hour + (86400, timedelta(days=1)), # 1 day + (1800.5, timedelta(seconds=1800.5)), # 30 minutes and 0.5 seconds + # Tuple/List input + ((1, 2, 30, 15), timedelta(days=1, hours=2, minutes=30, seconds=15)), + ([0, 10, 0, 0], timedelta(hours=10)), + ], +) +def test_to_timedelta_valid(input_value, expected_output): + """Test to_timedelta with valid inputs.""" + assert to_timedelta(input_value) == expected_output diff --git a/tests/test_logutil.py b/tests/test_logutil.py new file mode 100644 index 00000000..9573b37b --- /dev/null +++ b/tests/test_logutil.py @@ -0,0 +1,82 @@ +"""Test Module for logutil Module.""" + +import logging +import os +from logging.handlers import RotatingFileHandler + +import pytest + +from akkudoktoreos.logutil import get_logger + +# ----------------------------- +# get_logger +# ----------------------------- + + +@pytest.fixture +def clean_up_log_file(): + """Fixture to clean up log files after tests.""" + log_file = "test.log" + yield log_file + if os.path.exists(log_file): + os.remove(log_file) + + +def test_get_logger_console_logging(clean_up_log_file): + """Test logger creation with console logging.""" + logger = get_logger("test_logger", logging_level="DEBUG") + + # Check logger name + assert logger.name == "test_logger" + + # Check logger level + assert logger.level == logging.DEBUG + + # Check console handler is present + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.StreamHandler) + + +def test_get_logger_file_logging(clean_up_log_file): + """Test logger creation with file logging.""" + logger = get_logger("test_logger", log_file="test.log", logging_level="WARNING") + + # Check logger name + assert logger.name == "test_logger" + + # Check logger level + assert logger.level == logging.WARNING + + # Check console handler is present + assert len(logger.handlers) == 2 # One for console and one for file + assert isinstance(logger.handlers[0], logging.StreamHandler) + assert isinstance(logger.handlers[1], RotatingFileHandler) + + # Check file existence + assert os.path.exists("test.log") + + +def test_get_logger_no_file_logging(clean_up_log_file): + """Test logger creation without file logging.""" + logger = get_logger("test_logger") + + # Check logger name + assert logger.name == "test_logger" + + # Check logger level + assert logger.level == logging.INFO + + # Check no file handler is present + assert len(logger.handlers) >= 1 # First is console handler (maybe be pytest handler) + assert isinstance(logger.handlers[0], logging.StreamHandler) + + +def test_get_logger_with_invalid_level(clean_up_log_file): + """Test logger creation with an invalid logging level.""" + logger = get_logger("test_logger", logging_level="INVALID") + + # Check logger name + assert logger.name == "test_logger" + + # Check default logging level is DEBUG + assert logger.level == logging.DEBUG diff --git a/tests/test_pv_forecast.py b/tests/test_pv_forecast.py new file mode 100644 index 00000000..4c2c8e72 --- /dev/null +++ b/tests/test_pv_forecast.py @@ -0,0 +1,282 @@ +"""Test Module for PV Power Forecasting Module. + +This test module is designed to verify the functionality of the `PVForecast` class +and its methods in the `class_pv_forecast` module. The tests include validation for +forecast data processing, updating AC power measurements, retrieving forecast data, +and caching behavior. + +Fixtures: + sample_forecast_data: Provides sample forecast data in JSON format for testing. + pv_forecast_instance: Provides an instance of `PVForecast` class with sample data loaded. + +Test Cases: + - test_generate_cache_filename: Verifies correct cache filename generation based on URL and date. + - test_update_ac_power_measurement: Tests updating AC power measurement for a matching date. + - test_update_ac_power_measurement_no_match: Ensures no updates occur when there is no matching date. + - test_get_temperature_forecast_for_date: Tests retrieving the temperature forecast for a specific date. + - test_get_pv_forecast_for_date_range: Verifies retrieval of AC power forecast for a specified date range. + - test_get_forecast_dataframe: Ensures forecast data can be correctly converted into a Pandas DataFrame. + - test_cache_loading: Tests loading forecast data from a cached file to ensure caching works as expected. + +Usage: + This test module uses `pytest` and requires the `akkudoktoreos.class_pv_forecast.py` module to be present. + Run the tests using the command: `pytest test_pv_forecast.py`. + +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from akkudoktoreos.class_pv_forecast import PVForecast, validate_pv_forecast_data +from akkudoktoreos.datetimeutil import to_datetime + +DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") + +FILE_TESTDATA_PV_FORECAST_INPUT_1 = DIR_TESTDATA.joinpath("pv_forecast_input_1.json") +FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt") + + +@pytest.fixture +def sample_forecast_data(): + """Fixture that returns sample forecast data.""" + with open(FILE_TESTDATA_PV_FORECAST_INPUT_1, "r") as f_in: + input_data = json.load(f_in) + return input_data + + +@pytest.fixture +def sample_forecast_report(): + """Fixture that returns sample forecast data report.""" + with open(FILE_TESTDATA_PV_FORECAST_RESULT_1, "r") as f_res: + input_data = f_res.read() + return input_data + + +@pytest.fixture +def sample_forecast_start(sample_forecast_data): + """Fixture that returns the start date of the sample forecast data.""" + forecast_start_str = sample_forecast_data["values"][0][0]["datetime"] + assert forecast_start_str == "2024-10-06T00:00:00.000+02:00" + + timezone_name = sample_forecast_data["meta"]["timezone"] + assert timezone_name == "Europe/Berlin" + + forecast_start = to_datetime(forecast_start_str, to_timezone=timezone_name, to_naiv=True) + assert forecast_start == datetime(2024, 10, 6) + + return forecast_start + + +@pytest.fixture +def pv_forecast_empty_instance(): + """Fixture that returns an empty instance of PVForecast.""" + empty_instance = PVForecast() + assert empty_instance.get_forecast_start() is None + + return empty_instance + + +@pytest.fixture +def pv_forecast_instance(sample_forecast_data, sample_forecast_start): + """Fixture that returns an instance of PVForecast with sample data loaded.""" + pv_forecast = PVForecast( + data=sample_forecast_data, + forecast_start=sample_forecast_start, + prediction_hours=48, + ) + return pv_forecast + + +def test_validate_pv_forecast_data(sample_forecast_data): + """Test validation of PV forecast data on sample data.""" + ret = validate_pv_forecast_data({}) + assert ret is None + + ret = validate_pv_forecast_data(sample_forecast_data) + assert ret == "Akkudoktor" + + +def test_process_data(sample_forecast_data, sample_forecast_start): + """Test data processing using sample data.""" + pv_forecast_instance = PVForecast(forecast_start=sample_forecast_start) + + # Assure the start date is correctly set by init funtion + forecast_start = pv_forecast_instance.get_forecast_start() + expected_start = sample_forecast_start + assert forecast_start == expected_start + + # Assure the prediction hours are unset + assert pv_forecast_instance.prediction_hours is None + + # Load forecast with sample data - throws exceptions on error + pv_forecast_instance.process_data(data=sample_forecast_data) + + +def test_update_ac_power_measurement(pv_forecast_instance, sample_forecast_start): + """Test updating AC power measurement for a specific date.""" + forecast_start = pv_forecast_instance.get_forecast_start() + assert forecast_start == sample_forecast_start + + updated = pv_forecast_instance.update_ac_power_measurement(forecast_start, 1000) + assert updated is True + forecast_data = pv_forecast_instance.get_forecast_data() + assert forecast_data[0].ac_power_measurement == 1000 + + +def test_update_ac_power_measurement_no_match(pv_forecast_instance): + """Test updating AC power measurement where no date matches.""" + date_time = datetime(2023, 10, 2, 1, 0, 0) + updated = pv_forecast_instance.update_ac_power_measurement(date_time, 1000) + assert not updated + + +def test_get_temperature_forecast_for_date(pv_forecast_instance, sample_forecast_start): + """Test fetching temperature forecast for a specific date.""" + forecast_temps = pv_forecast_instance.get_temperature_forecast_for_date(sample_forecast_start) + assert len(forecast_temps) == 24 + assert forecast_temps[0] == 7.0 + assert forecast_temps[1] == 6.5 + assert forecast_temps[2] == 6.0 + + # Assure function bails out if there is no timezone name available for the system. + tz_name = pv_forecast_instance._tz_name + pv_forecast_instance._tz_name = None + with pytest.raises(Exception) as exc_info: + forecast_temps = pv_forecast_instance.get_temperature_forecast_for_date( + sample_forecast_start + ) + pv_forecast_instance._tz_name = tz_name + assert ( + exc_info.value.args[0] == "Processing without PV system timezone info ist not implemented!" + ) + + +def test_get_temperature_for_date_range(pv_forecast_instance, sample_forecast_start): + """Test fetching temperature forecast for a specific date range.""" + end_date = sample_forecast_start + timedelta(hours=24) + forecast_temps = pv_forecast_instance.get_temperature_for_date_range( + sample_forecast_start, end_date + ) + assert len(forecast_temps) == 48 + assert forecast_temps[0] == 7.0 + assert forecast_temps[1] == 6.5 + assert forecast_temps[2] == 6.0 + + # Assure function bails out if there is no timezone name available for the system. + tz_name = pv_forecast_instance._tz_name + pv_forecast_instance._tz_name = None + with pytest.raises(Exception) as exc_info: + forecast_temps = pv_forecast_instance.get_temperature_for_date_range( + sample_forecast_start, end_date + ) + pv_forecast_instance._tz_name = tz_name + assert ( + exc_info.value.args[0] == "Processing without PV system timezone info ist not implemented!" + ) + + +def test_get_forecast_for_date_range(pv_forecast_instance, sample_forecast_start): + """Test fetching AC power forecast for a specific date range.""" + end_date = sample_forecast_start + timedelta(hours=24) + forecast = pv_forecast_instance.get_pv_forecast_for_date_range(sample_forecast_start, end_date) + assert len(forecast) == 48 + assert forecast[0] == 0.0 + assert forecast[1] == 0.0 + assert forecast[2] == 0.0 + + # Assure function bails out if there is no timezone name available for the system. + tz_name = pv_forecast_instance._tz_name + pv_forecast_instance._tz_name = None + with pytest.raises(Exception) as exc_info: + forecast = pv_forecast_instance.get_pv_forecast_for_date_range( + sample_forecast_start, end_date + ) + pv_forecast_instance._tz_name = tz_name + assert ( + exc_info.value.args[0] == "Processing without PV system timezone info ist not implemented!" + ) + + +def test_get_forecast_dataframe(pv_forecast_instance): + """Test converting forecast data to a DataFrame.""" + df = pv_forecast_instance.get_forecast_dataframe() + assert len(df) == 288 + assert list(df.columns) == ["date_time", "dc_power", "ac_power", "windspeed_10m", "temperature"] + assert df.iloc[0]["dc_power"] == 0.0 + assert df.iloc[1]["ac_power"] == 0.0 + assert df.iloc[2]["temperature"] == 6.0 + + +def test_load_data_from_file(server, pv_forecast_empty_instance): + """Test loading data from file.""" + # load from valid address file path + filepath = FILE_TESTDATA_PV_FORECAST_INPUT_1 + data = pv_forecast_empty_instance.load_data_from_file(filepath) + assert len(data) > 0 + + +def test_load_data_from_url(server, pv_forecast_empty_instance): + """Test loading data from url.""" + # load from valid address of our server + url = f"{server}/gesamtlast_simple?year_energy=2000&" + data = pv_forecast_empty_instance.load_data_from_url(url) + assert len(data) > 0 + + # load from invalid address of our server + url = f"{server}/invalid?" + data = pv_forecast_empty_instance.load_data_from_url(url) + assert data == f"Failed to load data from `{url}`. Status Code: 404" + + +def test_load_data_from_url_with_caching( + server, pv_forecast_empty_instance, sample_forecast_data, sample_forecast_start +): + """Test loading data from url with cache.""" + # load from valid address of our server + url = f"{server}/gesamtlast_simple?year_energy=2000&" + data = pv_forecast_empty_instance.load_data_from_url_with_caching(url) + assert len(data) > 0 + + # load from invalid address of our server + url = f"{server}/invalid?" + data = pv_forecast_empty_instance.load_data_from_url_with_caching(url) + assert data == f"Failed to load data from `{url}`. Status Code: 404" + + +def test_report_ac_power_and_measurement(pv_forecast_instance, sample_forecast_report): + """Test reporting.""" + report = pv_forecast_instance.report_ac_power_and_measurement() + assert report == sample_forecast_report + + +def test_timezone_behaviour( + pv_forecast_instance, sample_forecast_report, sample_forecast_start, other_timezone +): + """Test PVForecast in another timezone.""" + current_time = datetime.now() + + # Test updating AC power measurement for a specific date. + date_time = pv_forecast_instance.get_forecast_start() + assert date_time == sample_forecast_start + updated = pv_forecast_instance.update_ac_power_measurement(date_time, 1000) + assert updated is True + forecast_data = pv_forecast_instance.get_forecast_data() + assert forecast_data[0].ac_power_measurement == 1000 + + # Test fetching temperature forecast for a specific date. + forecast_temps = pv_forecast_instance.get_temperature_forecast_for_date(sample_forecast_start) + assert len(forecast_temps) == 24 + assert forecast_temps[0] == 7.0 + assert forecast_temps[1] == 6.5 + assert forecast_temps[2] == 6.0 + + # Test fetching AC power forecast + end_date = sample_forecast_start + timedelta(hours=24) + forecast = pv_forecast_instance.get_pv_forecast_for_date_range(sample_forecast_start, end_date) + assert len(forecast) == 48 + assert forecast[0] == 1000.0 # changed before + assert forecast[1] == 0.0 + assert forecast[2] == 0.0 diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index d28aa4f0..00000000 --- a/tests/test_util.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Test Module for Utilities Module.""" - -import io -from datetime import datetime -from zoneinfo import ZoneInfo - -import pytest - -from akkudoktoreos.util import CacheFileStore, to_datetime - -# ----------------------------- -# to_datetime -# ----------------------------- - - -def test_to_datetime(): - """Test date conversion as needed by PV forecast data.""" - date_time = to_datetime( - "2024-10-07T10:20:30.000+02:00", to_timezone="Europe/Berlin", to_naiv=False - ) - expected_date_time = datetime(2024, 10, 7, 10, 20, 30, 0, tzinfo=ZoneInfo("Europe/Berlin")) - assert date_time == expected_date_time - - date_time = to_datetime( - "2024-10-07T10:20:30.000+02:00", to_timezone="Europe/Berlin", to_naiv=True - ) - expected_date_time = datetime(2024, 10, 7, 10, 20, 30, 0) - assert date_time == expected_date_time - - date_time = to_datetime("2024-10-07", to_timezone="Europe/Berlin", to_naiv=False) - expected_date_time = datetime(2024, 10, 7, 0, 0, 0, 0, tzinfo=ZoneInfo("Europe/Berlin")) - assert date_time == expected_date_time - - date_time = to_datetime("2024-10-07", to_timezone="Europe/Berlin", to_naiv=True) - expected_date_time = datetime(2024, 10, 7, 0, 0, 0, 0) - assert date_time == expected_date_time - - -# ----------------------------- -# CacheFileStore -# ----------------------------- - - -@pytest.fixture -def cache_store(): - """A pytest fixture that creates a new CacheFileStore instance for testing.""" - return CacheFileStore() - - -def test_generate_cache_file_key(cache_store): - """Test cache file key generation based on URL and date.""" - key = "http://example.com" - key_date = "2024-10-01" - cache_file_key, cache_file_key_date = cache_store._generate_cache_file_key(key, key_date) - expected_file_key = "0f6b92d1be8ef1e6a0b440de2963a7b847b54a8af267f2fab7f8756f30d733ac" - assert cache_file_key == expected_file_key - assert cache_file_key_date == key_date - - -def test_get_file_path(cache_store): - """Test get file path from cache file object.""" - cache_file = cache_store.create("test_file", mode="w+", suffix=".txt") - file_path = cache_store._get_file_path(cache_file) - - assert file_path is not None - - -def test_create_cache_file(cache_store): - """Test the creation of a cache file and ensure it is stored correctly.""" - # Create a cache file for today's date - cache_file = cache_store.create("test_file", mode="w+", suffix=".txt") - - # Check that the file exists in the store and is a file-like object - assert cache_file is not None - assert hasattr(cache_file, "name") - assert cache_file.name.endswith(".txt") - - # Write some data to the file - cache_file.seek(0) - cache_file.write("Test data") - cache_file.seek(0) # Reset file pointer - assert cache_file.read() == "Test data" - - -def test_get_cache_file(cache_store): - """Test retrieving an existing cache file by key.""" - # Create a cache file and write data to it - cache_file = cache_store.create("test_file", mode="w+") - cache_file.seek(0) - cache_file.write("Test data") - cache_file.seek(0) - - # Retrieve the cache file and verify the data - retrieved_file = cache_store.get("test_file") - assert retrieved_file is not None - retrieved_file.seek(0) - assert retrieved_file.read() == "Test data" - - -def test_set_custom_file_object(cache_store): - """Test setting a custom file-like object (BytesIO or StringIO) in the store.""" - # Create a BytesIO object and set it into the cache - file_obj = io.BytesIO(b"Binary data") - cache_store.set("binary_file", file_obj) - - # Retrieve the file from the store - retrieved_file = cache_store.get("binary_file") - assert isinstance(retrieved_file, io.BytesIO) - retrieved_file.seek(0) - assert retrieved_file.read() == b"Binary data" - - -def test_delete_cache_file(cache_store): - """Test deleting a cache file from the store.""" - # Create multiple cache files - cache_file1 = cache_store.create("file1") - assert hasattr(cache_file1, "name") - cache_file2 = cache_store.create("file2") - assert hasattr(cache_file2, "name") - - # Ensure the files are in the store - assert cache_store.get("file1") is cache_file1 - assert cache_store.get("file2") is cache_file2 - - # Delete cache files - cache_store.delete("file1") - cache_store.delete("file2") - - # Ensure the store is empty - assert cache_store.get("file1") is None - assert cache_store.get("file2") is None - - -def test_clear_cache_files(cache_store): - """Test clearing all cache files from the store.""" - # Create multiple cache files - cache_file1 = cache_store.create("file1") - assert hasattr(cache_file1, "name") - cache_file2 = cache_store.create("file2") - assert hasattr(cache_file2, "name") - - # Ensure the files are in the store - assert cache_store.get("file1") is cache_file1 - assert cache_store.get("file2") is cache_file2 - - # Clear all cache files - cache_store.clear() - - # Ensure the store is empty - assert cache_store.get("file1") is None - assert cache_store.get("file2") is None - - -def test_cache_file_with_date(cache_store): - """Test creating and retrieving cache files with a specific date.""" - # Use a specific date for cache file creation - specific_date = datetime(2023, 10, 10) - cache_file = cache_store.create("dated_file", key_date=specific_date) - - # Write data to the cache file - cache_file.write("Dated data") - cache_file.seek(0) - - # Retrieve the cache file with the specific date - retrieved_file = cache_store.get("dated_file", key_date=specific_date) - assert retrieved_file is not None - retrieved_file.seek(0) - assert retrieved_file.read() == "Dated data" - - -def test_recreate_existing_cache_file(cache_store): - """Test creating a cache file with an existing key does not overwrite the existing file.""" - # Create a cache file - cache_file = cache_store.create("test_file", mode="w+") - cache_file.write("Original data") - cache_file.seek(0) - - # Attempt to recreate the same file (should return the existing one) - new_file = cache_store.create("test_file") - assert new_file is cache_file # Should be the same object - new_file.seek(0) - assert new_file.read() == "Original data" # Data should be preserved - - # Assure cache file store is a singleton - cache_store2 = CacheFileStore() - new_file = cache_store2.get("test_file") - assert new_file is cache_file # Should be the same object - - -def test_cache_store_is_singleton(cache_store): - """Test re-creating a cache store provides the same store.""" - # Create a cache file - cache_file = cache_store.create("test_file", mode="w+") - cache_file.write("Original data") - cache_file.seek(0) - - # Assure cache file store is a singleton - cache_store2 = CacheFileStore() - new_file = cache_store2.get("test_file") - assert new_file is cache_file # Should be the same object diff --git a/tests/testdata/pv_forecast_input_1.json b/tests/testdata/pv_forecast_input_1.json new file mode 100644 index 00000000..ccc4cb43 --- /dev/null +++ b/tests/testdata/pv_forecast_input_1.json @@ -0,0 +1 @@ +{"meta":{"lat":50.8588,"lon":7.3747,"power":[5000,4800,1400,1600],"azimuth":[-10,-90,-40,5],"tilt":[7,7,60,45],"timezone":"Europe/Berlin","albedo":0.25,"past_days":5,"inverterEfficiency":0.8,"powerInverter":[5000,4800,1400,1600],"cellCoEff":-0.36,"range":false,"horizont":[[{"altitude":20,"azimuthFrom":-180,"azimuthTo":-90},{"altitude":27,"azimuthFrom":-90,"azimuthTo":0},{"altitude":22,"azimuthFrom":0,"azimuthTo":90},{"altitude":20,"azimuthFrom":90,"azimuthTo":180}],[{"altitude":30,"azimuthFrom":-180,"azimuthTo":-90},{"altitude":30,"azimuthFrom":-90,"azimuthTo":0},{"altitude":30,"azimuthFrom":0,"azimuthTo":90},{"altitude":50,"azimuthFrom":90,"azimuthTo":180}],[{"altitude":60,"azimuthFrom":-180,"azimuthTo":-90},{"altitude":30,"azimuthFrom":-90,"azimuthTo":0},{"altitude":0,"azimuthFrom":0,"azimuthTo":90},{"altitude":30,"azimuthFrom":90,"azimuthTo":180}],[{"altitude":45,"azimuthFrom":-180,"azimuthTo":-90},{"altitude":25,"azimuthFrom":-90,"azimuthTo":0},{"altitude":30,"azimuthFrom":0,"azimuthTo":90},{"altitude":60,"azimuthFrom":90,"azimuthTo":180}]],"horizontString":["20,27,22,20","30,30,30,50","60,30,0,30","45,25,30,60"]},"values":[[{"datetime":"2024-10-06T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.03892891605494,"sunAzimuth":163.14263622624128,"temperature":7,"relativehumidity_2m":88,"windspeed_10m":7.9},{"datetime":"2024-10-06T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.13778324543035,"sunAzimuth":-176.22585898864278,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":6.8},{"datetime":"2024-10-06T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.87117274960936,"sunAzimuth":-155.9729639229445,"temperature":6,"relativehumidity_2m":91,"windspeed_10m":5.9},{"datetime":"2024-10-06T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.70531092777589,"sunAzimuth":-137.8059489226708,"temperature":5.5,"relativehumidity_2m":92,"windspeed_10m":5.1},{"datetime":"2024-10-06T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.46443142893699,"sunAzimuth":-122.16602054266892,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.2},{"datetime":"2024-10-06T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-20.930765133481234,"sunAzimuth":-108.58249513077881,"temperature":4.9,"relativehumidity_2m":93,"windspeed_10m":5.8},{"datetime":"2024-10-06T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.710347315474053,"sunAzimuth":-96.31140508589108,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.5},{"datetime":"2024-10-06T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.2689443207496223,"sunAzimuth":-84.62890292197706,"temperature":5.3,"relativehumidity_2m":92,"windspeed_10m":7.6},{"datetime":"2024-10-06T08:00:00.000+02:00","dcPower":11.737826509478335,"power":9.390261207582668,"sunTilt":6.991906328571172,"sunAzimuth":-72.87999206290318,"temperature":5.5,"relativehumidity_2m":91,"windspeed_10m":8.4},{"datetime":"2024-10-06T09:00:00.000+02:00","dcPower":242.57051171912266,"power":194.05640937529813,"sunTilt":15.663160391528187,"sunAzimuth":-60.45596163553978,"temperature":6.3,"relativehumidity_2m":90,"windspeed_10m":9.3},{"datetime":"2024-10-06T10:00:00.000+02:00","dcPower":479.53210925713626,"power":383.625687405709,"sunTilt":23.268816289666535,"sunAzimuth":-46.79827360798693,"temperature":8,"relativehumidity_2m":85,"windspeed_10m":11.6},{"datetime":"2024-10-06T11:00:00.000+02:00","dcPower":2020.1663735831366,"power":1616.1330988665095,"sunTilt":29.234287239795027,"sunAzimuth":-31.503805204051176,"temperature":9.5,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-06T12:00:00.000+02:00","dcPower":1840.0674202135267,"power":1472.0539361708215,"sunTilt":32.93002248275174,"sunAzimuth":-14.578212396799534,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":16.3},{"datetime":"2024-10-06T13:00:00.000+02:00","dcPower":1958.118125361256,"power":1566.494500289005,"sunTilt":33.84613522696556,"sunAzimuth":3.3037874055175505,"temperature":10.9,"relativehumidity_2m":82,"windspeed_10m":16.9},{"datetime":"2024-10-06T14:00:00.000+02:00","dcPower":1862.34412446005,"power":1489.87529956804,"sunTilt":31.83736693728352,"sunAzimuth":20.94669333759787,"temperature":12.6,"relativehumidity_2m":74,"windspeed_10m":17.1},{"datetime":"2024-10-06T15:00:00.000+02:00","dcPower":2071.518360584685,"power":1657.2146884677481,"sunTilt":27.209548486852757,"sunAzimuth":37.29302345489315,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":17.8},{"datetime":"2024-10-06T16:00:00.000+02:00","dcPower":856.2189711080925,"power":684.9751768864741,"sunTilt":20.54547686442047,"sunAzimuth":51.93430385037965,"temperature":13.1,"relativehumidity_2m":73,"windspeed_10m":17.8},{"datetime":"2024-10-06T17:00:00.000+02:00","dcPower":806.8246131382091,"power":645.4596905105673,"sunTilt":12.4658413410018,"sunAzimuth":65.05541740712634,"temperature":12.6,"relativehumidity_2m":72,"windspeed_10m":15.3},{"datetime":"2024-10-06T18:00:00.000+02:00","dcPower":429.22798847242814,"power":343.38239077794253,"sunTilt":3.5065849097251456,"sunAzimuth":77.13919140741508,"temperature":11.9,"relativehumidity_2m":76,"windspeed_10m":14.8},{"datetime":"2024-10-06T19:00:00.000+02:00","dcPower":86.22924149013942,"power":68.98339319211154,"sunTilt":-5.8888854618235795,"sunAzimuth":88.76774476136781,"temperature":11.1,"relativehumidity_2m":79,"windspeed_10m":15.1},{"datetime":"2024-10-06T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.324219942418523,"sunAzimuth":100.56460329657087,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.2},{"datetime":"2024-10-06T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.385415066790006,"sunAzimuth":113.21108724923529,"temperature":10.3,"relativehumidity_2m":81,"windspeed_10m":13.4},{"datetime":"2024-10-06T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.56528997863786,"sunAzimuth":127.45995077522508,"temperature":10.3,"relativehumidity_2m":83,"windspeed_10m":13.4},{"datetime":"2024-10-06T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.19130152908581,"sunAzimuth":144.02419079232183,"temperature":10.7,"relativehumidity_2m":83,"windspeed_10m":13},{"datetime":"2024-10-07T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.4292477470776,"sunAzimuth":163.14429087891105,"temperature":11,"relativehumidity_2m":83,"windspeed_10m":13.6},{"datetime":"2024-10-07T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.517749495901406,"sunAzimuth":-176.10136944486192,"temperature":11,"relativehumidity_2m":87,"windspeed_10m":11.2},{"datetime":"2024-10-07T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.22254930467857,"sunAzimuth":-155.74445709329385,"temperature":10.9,"relativehumidity_2m":91,"windspeed_10m":9.8},{"datetime":"2024-10-07T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.020882886444426,"sunAzimuth":-137.5192133151141,"temperature":11,"relativehumidity_2m":93,"windspeed_10m":8.7},{"datetime":"2024-10-07T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.748563322135755,"sunAzimuth":-121.8586066543482,"temperature":11.4,"relativehumidity_2m":94,"windspeed_10m":8.9},{"datetime":"2024-10-07T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.193270783038106,"sunAzimuth":-108.27337691467278,"temperature":11.5,"relativehumidity_2m":95,"windspeed_10m":11},{"datetime":"2024-10-07T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.962117012521006,"sunAzimuth":-96.00713008699226,"temperature":12,"relativehumidity_2m":94,"windspeed_10m":8.4},{"datetime":"2024-10-07T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.520439907672165,"sunAzimuth":-84.33068309080377,"temperature":12.2,"relativehumidity_2m":95,"windspeed_10m":9.3},{"datetime":"2024-10-07T08:00:00.000+02:00","dcPower":5.20619301519954,"power":4.1649544121596325,"sunTilt":6.730829735879827,"sunAzimuth":-72.58838243599898,"temperature":12.7,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-07T09:00:00.000+02:00","dcPower":106.2638790743732,"power":85.01110325949855,"sunTilt":15.383293627086166,"sunAzimuth":-60.174375329765816,"temperature":13.3,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-07T10:00:00.000+02:00","dcPower":425.50881675323336,"power":340.4070534025867,"sunTilt":22.96230371458641,"sunAzimuth":-46.53655309716923,"temperature":14.4,"relativehumidity_2m":91,"windspeed_10m":8.9},{"datetime":"2024-10-07T11:00:00.000+02:00","dcPower":443.2481574750867,"power":354.5985259800694,"sunTilt":28.896671975172765,"sunAzimuth":-31.281195510232457,"temperature":15.2,"relativehumidity_2m":91,"windspeed_10m":10.9},{"datetime":"2024-10-07T12:00:00.000+02:00","dcPower":782.1822024422786,"power":625.7457619538229,"sunTilt":32.56343562699629,"sunAzimuth":-14.421329305847635,"temperature":16,"relativehumidity_2m":89,"windspeed_10m":8.4},{"datetime":"2024-10-07T13:00:00.000+02:00","dcPower":1472.4584677473485,"power":1177.966774197879,"sunTilt":33.46089993111447,"sunAzimuth":3.3732496911031458,"temperature":17,"relativehumidity_2m":82,"windspeed_10m":10.5},{"datetime":"2024-10-07T14:00:00.000+02:00","dcPower":1709.702157101353,"power":1367.7617256810825,"sunTilt":31.448234246769605,"sunAzimuth":20.927108911856653,"temperature":18,"relativehumidity_2m":75,"windspeed_10m":9.8},{"datetime":"2024-10-07T15:00:00.000+02:00","dcPower":1624.9532433869356,"power":1299.9625947095485,"sunTilt":26.828539273003113,"sunAzimuth":37.20261442198752,"temperature":18.6,"relativehumidity_2m":70,"windspeed_10m":5.8},{"datetime":"2024-10-07T16:00:00.000+02:00","dcPower":878.2827125323747,"power":702.6261700258998,"sunTilt":20.17798028996772,"sunAzimuth":51.797067137147856,"temperature":18.9,"relativehumidity_2m":70,"windspeed_10m":7.4},{"datetime":"2024-10-07T17:00:00.000+02:00","dcPower":790.4767257839096,"power":632.3813806271278,"sunTilt":12.11146577521795,"sunAzimuth":64.89046144901918,"temperature":18.4,"relativehumidity_2m":72,"windspeed_10m":7.2},{"datetime":"2024-10-07T18:00:00.000+02:00","dcPower":360.64272843920315,"power":288.5141827513625,"sunTilt":3.1615292467832945,"sunAzimuth":76.95875071433478,"temperature":17.4,"relativehumidity_2m":74,"windspeed_10m":9.5},{"datetime":"2024-10-07T19:00:00.000+02:00","dcPower":79.53042904330093,"power":63.62434323464075,"sunTilt":-6.2300935503595385,"sunAzimuth":88.57949984494998,"temperature":16.4,"relativehumidity_2m":79,"windspeed_10m":7.6},{"datetime":"2024-10-07T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.667810875617224,"sunAzimuth":100.37506201699385,"temperature":15.4,"relativehumidity_2m":87,"windspeed_10m":9.2},{"datetime":"2024-10-07T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.737727405113922,"sunAzimuth":113.02960837119129,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":10.9},{"datetime":"2024-10-07T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.93153539182831,"sunAzimuth":127.30408938398604,"temperature":14.5,"relativehumidity_2m":94,"windspeed_10m":10.7},{"datetime":"2024-10-07T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.572641175501424,"sunAzimuth":143.925225436442,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":11.6},{"datetime":"2024-10-08T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.818121034022454,"sunAzimuth":163.14384998115503,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":15.8},{"datetime":"2024-10-08T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.8965146817323,"sunAzimuth":-175.97781528741945,"temperature":14.8,"relativehumidity_2m":93,"windspeed_10m":14},{"datetime":"2024-10-08T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.572826067170936,"sunAzimuth":-155.51612909180187,"temperature":15.4,"relativehumidity_2m":94,"windspeed_10m":11.2},{"datetime":"2024-10-08T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.33548533853669,"sunAzimuth":-137.23273090041528,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":12.2},{"datetime":"2024-10-08T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.031970973409337,"sunAzimuth":-121.55190085899577,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":11.2},{"datetime":"2024-10-08T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.45536424486834,"sunAzimuth":-107.96544161465735,"temperature":15.8,"relativehumidity_2m":94,"windspeed_10m":10},{"datetime":"2024-10-08T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.213784351950519,"sunAzimuth":-95.70446004731738,"temperature":15.8,"relativehumidity_2m":93,"windspeed_10m":10.8},{"datetime":"2024-10-08T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.772091353585381,"sunAzimuth":-84.03447848078322,"temperature":15.7,"relativehumidity_2m":93,"windspeed_10m":10.5},{"datetime":"2024-10-08T08:00:00.000+02:00","dcPower":4.121973110641917,"power":3.2975784885135333,"sunTilt":6.469429830924286,"sunAzimuth":-72.29923977528891,"temperature":15.7,"relativehumidity_2m":90,"windspeed_10m":10.2},{"datetime":"2024-10-08T09:00:00.000+02:00","dcPower":215.35955387207488,"power":172.28764309765992,"sunTilt":15.10307050684314,"sunAzimuth":-59.895772492079,"temperature":15.7,"relativehumidity_2m":89,"windspeed_10m":8.3},{"datetime":"2024-10-08T10:00:00.000+02:00","dcPower":575.8348468079607,"power":460.6678774463686,"sunTilt":22.655587204347487,"sunAzimuth":-46.27834028428006,"temperature":16.3,"relativehumidity_2m":86,"windspeed_10m":10.6},{"datetime":"2024-10-08T11:00:00.000+02:00","dcPower":973.7759889631144,"power":779.0207911704915,"sunTilt":28.55922255887939,"sunAzimuth":-31.06241498925654,"temperature":17,"relativehumidity_2m":85,"windspeed_10m":9},{"datetime":"2024-10-08T12:00:00.000+02:00","dcPower":1253.7838457017754,"power":1003.0270765614204,"sunTilt":32.19754822667087,"sunAzimuth":-14.26805544520534,"temperature":17.6,"relativehumidity_2m":82,"windspeed_10m":13.1},{"datetime":"2024-10-08T13:00:00.000+02:00","dcPower":1709.4582030972936,"power":1367.5665624778349,"sunTilt":33.07688907994966,"sunAzimuth":3.4399896103944245,"temperature":18.3,"relativehumidity_2m":73,"windspeed_10m":12.7},{"datetime":"2024-10-08T14:00:00.000+02:00","dcPower":1534.8297541086074,"power":1227.863803286886,"sunTilt":31.060677746554727,"sunAzimuth":20.90593811884074,"temperature":18.2,"relativehumidity_2m":71,"windspeed_10m":13.2},{"datetime":"2024-10-08T15:00:00.000+02:00","dcPower":1356.474994260473,"power":1085.1799954083785,"sunTilt":26.44928138282813,"sunAzimuth":37.11144846293623,"temperature":18.1,"relativehumidity_2m":71,"windspeed_10m":13.3},{"datetime":"2024-10-08T16:00:00.000+02:00","dcPower":863.9472141756544,"power":691.1577713405236,"sunTilt":19.81233269039211,"sunAzimuth":51.65943401571543,"temperature":17.4,"relativehumidity_2m":73,"windspeed_10m":9.2},{"datetime":"2024-10-08T17:00:00.000+02:00","dcPower":616.9390616093008,"power":493.55124928744067,"sunTilt":11.759033809196055,"sunAzimuth":64.72512518734406,"temperature":16.9,"relativehumidity_2m":75,"windspeed_10m":6.3},{"datetime":"2024-10-08T18:00:00.000+02:00","dcPower":352.2716884355315,"power":281.8173507484252,"sunTilt":2.818529310708217,"sunAzimuth":76.77774147088243,"temperature":16,"relativehumidity_2m":75,"windspeed_10m":8.6},{"datetime":"2024-10-08T19:00:00.000+02:00","dcPower":79.91850169407824,"power":63.9348013552626,"sunTilt":-6.569128186596377,"sunAzimuth":88.39036262615889,"temperature":15,"relativehumidity_2m":82,"windspeed_10m":5.8},{"datetime":"2024-10-08T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.00913432264249,"sunAzimuth":100.18418560559792,"temperature":14.5,"relativehumidity_2m":89,"windspeed_10m":4.7},{"datetime":"2024-10-08T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.087746753502067,"sunAzimuth":112.84624373263145,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":8.4},{"datetime":"2024-10-08T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.29558919415396,"sunAzimuth":127.1457755347392,"temperature":14,"relativehumidity_2m":88,"windspeed_10m":8.6},{"datetime":"2024-10-08T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.95205668980692,"sunAzimuth":143.82353531827584,"temperature":13,"relativehumidity_2m":91,"windspeed_10m":7.2},{"datetime":"2024-10-09T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.20543351779722,"sunAzimuth":163.1411836959831,"temperature":12.6,"relativehumidity_2m":92,"windspeed_10m":7.8},{"datetime":"2024-10-09T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.27398595213309,"sunAzimuth":-175.8553370630724,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.5},{"datetime":"2024-10-09T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.92193289241474,"sunAzimuth":-155.28812310014723,"temperature":12.7,"relativehumidity_2m":92,"windspeed_10m":10.5},{"datetime":"2024-10-09T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.64906836752005,"sunAzimuth":-136.94664669208646,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":12.3},{"datetime":"2024-10-09T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.314619206373006,"sunAzimuth":-121.24604384005964,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.2},{"datetime":"2024-10-09T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.71701795569338,"sunAzimuth":-107.65882183814558,"temperature":13.2,"relativehumidity_2m":90,"windspeed_10m":12.9},{"datetime":"2024-10-09T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.465322674764078,"sunAzimuth":-95.40351983003244,"temperature":13.3,"relativehumidity_2m":89,"windspeed_10m":13.5},{"datetime":"2024-10-09T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.023867147662328,"sunAzimuth":-83.74040781223535,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":13.2},{"datetime":"2024-10-09T08:00:00.000+02:00","dcPower":1.0406185617178385,"power":0.8324948493742709,"sunTilt":6.207747953736028,"sunAzimuth":-72.01267721735641,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":11.8},{"datetime":"2024-10-09T09:00:00.000+02:00","dcPower":56.142707531844685,"power":44.91416602547575,"sunTilt":14.82254636430179,"sunAzimuth":-59.62025856421435,"temperature":12.8,"relativehumidity_2m":93,"windspeed_10m":11.8},{"datetime":"2024-10-09T10:00:00.000+02:00","dcPower":178.65989116760815,"power":142.92791293408652,"sunTilt":22.348738640203063,"sunAzimuth":-46.023727432629336,"temperature":13.1,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T11:00:00.000+02:00","dcPower":364.3024142519385,"power":291.4419314015508,"sunTilt":28.22202694317921,"sunAzimuth":-30.847536638720733,"temperature":13.7,"relativehumidity_2m":92,"windspeed_10m":12.8},{"datetime":"2024-10-09T12:00:00.000+02:00","dcPower":541.4560120701284,"power":433.16480965610276,"sunTilt":31.832460658762763,"sunAzimuth":-14.118446086753368,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":12},{"datetime":"2024-10-09T13:00:00.000+02:00","dcPower":498.7759057406506,"power":399.02072459252054,"sunTilt":32.69421193791607,"sunAzimuth":3.503957762689472,"temperature":14.2,"relativehumidity_2m":90,"windspeed_10m":11.5},{"datetime":"2024-10-09T14:00:00.000+02:00","dcPower":371.1850633611338,"power":296.9480506889071,"sunTilt":30.674814714273392,"sunAzimuth":20.883130296493302,"temperature":13.9,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T15:00:00.000+02:00","dcPower":211.70593908566224,"power":169.3647512685298,"sunTilt":26.07189945210854,"sunAzimuth":37.01947906146245,"temperature":13.8,"relativehumidity_2m":92,"windspeed_10m":13.8},{"datetime":"2024-10-09T16:00:00.000+02:00","dcPower":162.78835470536214,"power":130.23068376428972,"sunTilt":19.448663917806936,"sunAzimuth":51.521368716140636,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":14.5},{"datetime":"2024-10-09T17:00:00.000+02:00","dcPower":108.77138997105102,"power":87.01711197684082,"sunTilt":11.408678227135676,"sunAzimuth":64.55938454107346,"temperature":13.5,"relativehumidity_2m":90,"windspeed_10m":15.8},{"datetime":"2024-10-09T18:00:00.000+02:00","dcPower":64.83235444876868,"power":51.86588355901495,"sunTilt":2.4777195309842384,"sunAzimuth":76.59614824640437,"temperature":13.4,"relativehumidity_2m":92,"windspeed_10m":13.4},{"datetime":"2024-10-09T19:00:00.000+02:00","dcPower":14.529021929144772,"power":11.623217543315818,"sunTilt":-6.905853552820109,"sunAzimuth":88.20032142925027,"temperature":13.6,"relativehumidity_2m":93,"windspeed_10m":14.5},{"datetime":"2024-10-09T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.348052794342024,"sunAzimuth":99.99195941586059,"temperature":13.7,"relativehumidity_2m":94,"windspeed_10m":15.3},{"datetime":"2024-10-09T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.43533401280622,"sunAzimuth":112.66096568522038,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":15.5},{"datetime":"2024-10-09T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.657312701408955,"sunAzimuth":126.98495418142949,"temperature":14.2,"relativehumidity_2m":93,"windspeed_10m":14.3},{"datetime":"2024-10-09T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.32941598688323,"sunAzimuth":143.71902473598436,"temperature":14.8,"relativehumidity_2m":90,"windspeed_10m":17.6},{"datetime":"2024-10-10T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.59106942107243,"sunAzimuth":163.13616076353475,"temperature":15.3,"relativehumidity_2m":89,"windspeed_10m":16.9},{"datetime":"2024-10-10T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.65007030458014,"sunAzimuth":-175.73407816523158,"temperature":15,"relativehumidity_2m":92,"windspeed_10m":15.1},{"datetime":"2024-10-10T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.26979987634531,"sunAzimuth":-155.060585251648,"temperature":14.9,"relativehumidity_2m":91,"windspeed_10m":15.3},{"datetime":"2024-10-10T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.96158240518052,"sunAzimuth":-136.66110792848332,"temperature":13.8,"relativehumidity_2m":97,"windspeed_10m":18.8},{"datetime":"2024-10-10T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.59647297796838,"sunAzimuth":-120.94117758387233,"temperature":12.4,"relativehumidity_2m":94,"windspeed_10m":25.6},{"datetime":"2024-10-10T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.97820409915596,"sunAzimuth":-107.35365077473413,"temperature":12.1,"relativehumidity_2m":92,"windspeed_10m":22.8},{"datetime":"2024-10-10T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.716704637744286,"sunAzimuth":-95.1044343497123,"temperature":12,"relativehumidity_2m":95,"windspeed_10m":19.5},{"datetime":"2024-10-10T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.275734703378654,"sunAzimuth":-83.4485894065161,"temperature":11.8,"relativehumidity_2m":93,"windspeed_10m":17.2},{"datetime":"2024-10-10T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":5.945826827444097,"sunAzimuth":-71.72880701980833,"temperature":12.1,"relativehumidity_2m":95,"windspeed_10m":16.3},{"datetime":"2024-10-10T09:00:00.000+02:00","dcPower":28.679605119169743,"power":22.943684095335797,"sunTilt":14.541778076840487,"sunAzimuth":-59.34793757422427,"temperature":12.1,"relativehumidity_2m":94,"windspeed_10m":14.7},{"datetime":"2024-10-10T10:00:00.000+02:00","dcPower":84.33498019934706,"power":67.46798415947765,"sunTilt":22.041831409435048,"sunAzimuth":-45.7728048820313,"temperature":12.2,"relativehumidity_2m":92,"windspeed_10m":15},{"datetime":"2024-10-10T11:00:00.000+02:00","dcPower":156.79561502122667,"power":125.43649201698133,"sunTilt":27.885174335468335,"sunAzimuth":-30.63663127573816,"temperature":12.5,"relativehumidity_2m":92,"windspeed_10m":15.7},{"datetime":"2024-10-10T12:00:00.000+02:00","dcPower":752.5340778483028,"power":602.0272622786423,"sunTilt":31.46827419380231,"sunAzimuth":-13.972554382839954,"temperature":12.8,"relativehumidity_2m":89,"windspeed_10m":18.5},{"datetime":"2024-10-10T13:00:00.000+02:00","dcPower":442.48615809817903,"power":353.98892647854325,"sunTilt":32.31297830760232,"sunAzimuth":3.5651067967559134,"temperature":12.3,"relativehumidity_2m":91,"windspeed_10m":17.7},{"datetime":"2024-10-10T14:00:00.000+02:00","dcPower":1051.0549131692967,"power":840.8439305354374,"sunTilt":30.29076264178465,"sunAzimuth":20.858636821509823,"temperature":13.1,"relativehumidity_2m":87,"windspeed_10m":16},{"datetime":"2024-10-10T15:00:00.000+02:00","dcPower":1158.2998809674602,"power":926.6399047739683,"sunTilt":25.696518065354862,"sunAzimuth":36.92666158608413,"temperature":13.2,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-10T16:00:00.000+02:00","dcPower":635.9116798103347,"power":508.7293438482678,"sunTilt":19.087103602605772,"sunAzimuth":51.382837136526646,"temperature":13,"relativehumidity_2m":81,"windspeed_10m":15.1},{"datetime":"2024-10-10T17:00:00.000+02:00","dcPower":499.23307173700306,"power":399.38645738960247,"sunTilt":11.060531491249431,"sunAzimuth":64.39321702308736,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":13.3},{"datetime":"2024-10-10T18:00:00.000+02:00","dcPower":333.928005717175,"power":267.14240457374,"sunTilt":2.1392339596675116,"sunAzimuth":76.4139573135511,"temperature":12.3,"relativehumidity_2m":79,"windspeed_10m":12.2},{"datetime":"2024-10-10T19:00:00.000+02:00","dcPower":61.72489409515999,"power":49.379915276127996,"sunTilt":-7.240134220715796,"sunAzimuth":88.00936653945188,"temperature":11,"relativehumidity_2m":80,"windspeed_10m":7},{"datetime":"2024-10-10T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.684429132286564,"sunAzimuth":99.79837109714164,"temperature":10.6,"relativehumidity_2m":84,"windspeed_10m":5.2},{"datetime":"2024-10-10T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.780350237977437,"sunAzimuth":112.47374911967749,"temperature":10.1,"relativehumidity_2m":85,"windspeed_10m":4.3},{"datetime":"2024-10-10T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.01656707008997,"sunAzimuth":126.82157256673277,"temperature":10.2,"relativehumidity_2m":84,"windspeed_10m":9.2},{"datetime":"2024-10-10T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.704586491662205,"sunAzimuth":143.61159893587703,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-11T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.97491243886403,"sunAzimuth":163.128648637255,"temperature":9.3,"relativehumidity_2m":92,"windspeed_10m":10.8},{"datetime":"2024-10-11T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.02467456169168,"sunAzimuth":-175.6141848122969,"temperature":8.9,"relativehumidity_2m":94,"windspeed_10m":8.7},{"datetime":"2024-10-11T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.61635732682025,"sunAzimuth":-154.8336646265192,"temperature":8.3,"relativehumidity_2m":91,"windspeed_10m":2.9},{"datetime":"2024-10-11T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.27297819203817,"sunAzimuth":-136.37626404685486,"temperature":8.3,"relativehumidity_2m":93,"windspeed_10m":3.6},{"datetime":"2024-10-11T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.877497329298222,"sunAzimuth":-120.63744534201388,"temperature":8.3,"relativehumidity_2m":95,"windspeed_10m":8.7},{"datetime":"2024-10-11T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.238894548955713,"sunAzimuth":-107.05006215076084,"temperature":8.3,"relativehumidity_2m":98,"windspeed_10m":6.5},{"datetime":"2024-10-11T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.96790216264699,"sunAzimuth":-94.80732853145587,"temperature":8.3,"relativehumidity_2m":96,"windspeed_10m":8.6},{"datetime":"2024-10-11T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.5276603134629134,"sunAzimuth":-83.1591411410959,"temperature":7.9,"relativehumidity_2m":95,"windspeed_10m":7.8},{"datetime":"2024-10-11T08:00:00.000+02:00","dcPower":1.0600042231633038,"power":0.8480033785306431,"sunTilt":5.683710585945252,"sunAzimuth":-71.44774052199065,"temperature":7.5,"relativehumidity_2m":96,"windspeed_10m":6.9},{"datetime":"2024-10-11T09:00:00.000+02:00","dcPower":132.00210321891544,"power":105.60168257513236,"sunTilt":14.260824082236146,"sunAzimuth":-59.0789120948653,"temperature":7.8,"relativehumidity_2m":93,"windspeed_10m":8.6},{"datetime":"2024-10-11T10:00:00.000+02:00","dcPower":462.0210524027087,"power":369.61684192216694,"sunTilt":21.734940400779433,"sunAzimuth":-45.525661029390804,"temperature":8.1,"relativehumidity_2m":89,"windspeed_10m":8.2},{"datetime":"2024-10-11T11:00:00.000+02:00","dcPower":1798.689582839002,"power":1438.9516662712017,"sunTilt":27.54875518315372,"sunAzimuth":-30.429767533564867,"temperature":9,"relativehumidity_2m":84,"windspeed_10m":7.6},{"datetime":"2024-10-11T12:00:00.000+02:00","dcPower":1894.8011928812136,"power":1515.840954304971,"sunTilt":31.105090976135774,"sunAzimuth":-13.830431372703888,"temperature":9.8,"relativehumidity_2m":79,"windspeed_10m":7.4},{"datetime":"2024-10-11T13:00:00.000+02:00","dcPower":1985.9627540265499,"power":1588.77020322124,"sunTilt":31.9332985100557,"sunAzimuth":3.6233914143032355,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":6.5},{"datetime":"2024-10-11T14:00:00.000+02:00","dcPower":2210.0949240181085,"power":1768.0759392144869,"sunTilt":29.908639212205873,"sunAzimuth":20.83241114244682,"temperature":10.8,"relativehumidity_2m":71,"windspeed_10m":5.1},{"datetime":"2024-10-11T15:00:00.000+02:00","dcPower":2011.3085922284695,"power":1609.0468737827757,"sunTilt":25.3232617284627,"sunAzimuth":36.83295334168066,"temperature":11.1,"relativehumidity_2m":69,"windspeed_10m":4.3},{"datetime":"2024-10-11T16:00:00.000+02:00","dcPower":648.2559726986733,"power":518.6047781589386,"sunTilt":18.727781114603793,"sunAzimuth":51.243806920166435,"temperature":11.2,"relativehumidity_2m":68,"windspeed_10m":4},{"datetime":"2024-10-11T17:00:00.000+02:00","dcPower":560.3066399417806,"power":448.24531195342456,"sunTilt":10.714725694488843,"sunAzimuth":64.22660182758023,"temperature":10.9,"relativehumidity_2m":71,"windspeed_10m":4.1},{"datetime":"2024-10-11T18:00:00.000+02:00","dcPower":298.6186217115864,"power":238.89489736926913,"sunTilt":1.803206213409536,"sunAzimuth":76.23115674859116,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":0.5},{"datetime":"2024-10-11T19:00:00.000+02:00","dcPower":62.17166718764051,"power":49.73733375011241,"sunTilt":-7.571835214892863,"sunAzimuth":87.81749031617603,"temperature":9,"relativehumidity_2m":79,"windspeed_10m":1.9},{"datetime":"2024-10-11T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.0181265759899,"sunAzimuth":99.60341073267222,"temperature":7.9,"relativehumidity_2m":83,"windspeed_10m":2.4},{"datetime":"2024-10-11T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.122656701936815,"sunAzimuth":112.28457164140065,"temperature":7,"relativehumidity_2m":85,"windspeed_10m":2.9},{"datetime":"2024-10-11T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.37321335134456,"sunAzimuth":126.65558045028693,"temperature":6.5,"relativehumidity_2m":86,"windspeed_10m":3},{"datetime":"2024-10-11T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.07743516418665,"sunAzimuth":143.5011643499793,"temperature":6,"relativehumidity_2m":88,"windspeed_10m":3.3},{"datetime":"2024-10-12T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.35684572865986,"sunAzimuth":163.11851362268473,"temperature":5.4,"relativehumidity_2m":89,"windspeed_10m":4.8},{"datetime":"2024-10-12T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.397705346785955,"sunAzimuth":-175.49580600360676,"temperature":5.1,"relativehumidity_2m":90,"windspeed_10m":4.7},{"datetime":"2024-10-12T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.961535734103165,"sunAzimuth":-154.60751324634,"temperature":4.9,"relativehumidity_2m":90,"windspeed_10m":5.1},{"datetime":"2024-10-12T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.58320673599567,"sunAzimuth":-136.09226665392447,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.1},{"datetime":"2024-10-12T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.157657336040163,"sunAzimuth":-120.33499159290702,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.499060815350038,"sunAzimuth":-106.7481901878237,"temperature":4.8,"relativehumidity_2m":91,"windspeed_10m":5},{"datetime":"2024-10-12T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.218886384957228,"sunAzimuth":-94.51232726794028,"temperature":4.6,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.77960910849284,"sunAzimuth":-82.87218040699524,"temperature":4.5,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T08:00:00.000+02:00","dcPower":1.070056047616508,"power":0.8560448380932064,"sunTilt":5.421444806545091,"sunAzimuth":-71.16958809655219,"temperature":4.7,"relativehumidity_2m":92,"windspeed_10m":6.6},{"datetime":"2024-10-12T09:00:00.000+02:00","dcPower":197.4003911897386,"power":157.9203129517909,"sunTilt":13.979744388367903,"sunAzimuth":-58.8132832097472,"temperature":5.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-12T10:00:00.000+02:00","dcPower":550.3000645182516,"power":440.24005161460127,"sunTilt":21.42814200059926,"sunAzimuth":-45.28238230412684,"temperature":7.3,"relativehumidity_2m":87,"windspeed_10m":9.9},{"datetime":"2024-10-12T11:00:00.000+02:00","dcPower":962.495254869171,"power":769.9962038953369,"sunTilt":27.21286115693797,"sunAzimuth":-30.227011858140273,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":10.4},{"datetime":"2024-10-12T12:00:00.000+02:00","dcPower":1839.8932783620644,"power":1471.9146226896517,"sunTilt":30.743014004100917,"sunAzimuth":-13.692125978099353,"temperature":10.1,"relativehumidity_2m":82,"windspeed_10m":12.3},{"datetime":"2024-10-12T13:00:00.000+02:00","dcPower":1693.6453312654874,"power":1354.9162650123899,"sunTilt":31.555283363449327,"sunAzimuth":3.6787683789403496,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.4},{"datetime":"2024-10-12T14:00:00.000+02:00","dcPower":1646.0264815435514,"power":1316.8211852348413,"sunTilt":29.52856227644022,"sunAzimuth":20.80440880713897,"temperature":11.2,"relativehumidity_2m":80,"windspeed_10m":11.3},{"datetime":"2024-10-12T15:00:00.000+02:00","dcPower":1236.85137350003,"power":989.481098800024,"sunTilt":24.95225483898691,"sunAzimuth":36.7383136221432,"temperature":11.7,"relativehumidity_2m":80,"windspeed_10m":9.7},{"datetime":"2024-10-12T16:00:00.000+02:00","dcPower":741.2076560280071,"power":592.9661248224057,"sunTilt":18.37082552430316,"sunAzimuth":51.10424752804786,"temperature":11.5,"relativehumidity_2m":82,"windspeed_10m":8.4},{"datetime":"2024-10-12T17:00:00.000+02:00","dcPower":487.7543072254275,"power":390.203445780342,"sunTilt":10.371392509810583,"sunAzimuth":64.05951991866706,"temperature":11.1,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-12T18:00:00.000+02:00","dcPower":176.62539380738255,"power":141.30031504590605,"sunTilt":1.4697694157713943,"sunAzimuth":76.0477365277625,"temperature":10.5,"relativehumidity_2m":87,"windspeed_10m":8.4},{"datetime":"2024-10-12T19:00:00.000+02:00","dcPower":27.32951530385693,"power":21.863612243085544,"sunTilt":-7.900822080898683,"sunAzimuth":87.62468730812897,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":8.5},{"datetime":"2024-10-12T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.349008833561726,"sunAzimuth":99.4070709783674,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":8.6},{"datetime":"2024-10-12T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.462114969564762,"sunAzimuth":112.09341375841275,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":9.2},{"datetime":"2024-10-12T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.7271125456906,"sunAzimuth":126.48693033734844,"temperature":9.7,"relativehumidity_2m":92,"windspeed_10m":8},{"datetime":"2024-10-12T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.447828525061254,"sunAzimuth":143.38762883737587,"temperature":9.4,"relativehumidity_2m":94,"windspeed_10m":7.3},{"datetime":"2024-10-13T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.73675190204195,"sunAzimuth":163.1056210331007,"temperature":9.3,"relativehumidity_2m":94,"windspeed_10m":6.5},{"datetime":"2024-10-13T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.76906905918878,"sunAzimuth":-175.37909348313417,"temperature":9.1,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-13T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.30526573902631,"sunAzimuth":-154.3822860597556,"temperature":9.5,"relativehumidity_2m":92,"windspeed_10m":10},{"datetime":"2024-10-13T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.89221927072221,"sunAzimuth":-135.80926949668307,"temperature":9.6,"relativehumidity_2m":90,"windspeed_10m":16.3},{"datetime":"2024-10-13T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.436918053719957,"sunAzimuth":-120.03396199414307,"temperature":8.8,"relativehumidity_2m":87,"windspeed_10m":18},{"datetime":"2024-10-13T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.75867399010402,"sunAzimuth":-106.44816955814555,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":15.5},{"datetime":"2024-10-13T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.469627601285604,"sunAzimuth":-94.21955537307969,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":17},{"datetime":"2024-10-13T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.0315450153141486,"sunAzimuth":-82.58782406375519,"temperature":7.2,"relativehumidity_2m":82,"windspeed_10m":16.2},{"datetime":"2024-10-13T08:00:00.000+02:00","dcPower":1.0628761730070764,"power":0.8503009384056611,"sunTilt":5.159076534957485,"sunAzimuth":-70.89445910846419,"temperature":6.7,"relativehumidity_2m":84,"windspeed_10m":16.8},{"datetime":"2024-10-13T09:00:00.000+02:00","dcPower":145.59209961839045,"power":116.47367969471236,"sunTilt":13.698600583367673,"sunAzimuth":-58.55115047580995,"temperature":6.9,"relativehumidity_2m":86,"windspeed_10m":15.5},{"datetime":"2024-10-13T10:00:00.000+02:00","dcPower":420.1869623239068,"power":336.14956985912545,"sunTilt":21.12151408595382,"sunAzimuth":-45.043053146408546,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":22.7},{"datetime":"2024-10-13T11:00:00.000+02:00","dcPower":522.9364319872756,"power":418.34914558982047,"sunTilt":26.877585132999467,"sunAzimuth":-30.028428502780528,"temperature":8.1,"relativehumidity_2m":83,"windspeed_10m":19.5},{"datetime":"2024-10-13T12:00:00.000+02:00","dcPower":928.7775408499571,"power":743.0220326799657,"sunTilt":30.382147107830978,"sunAzimuth":-13.557685005032592,"temperature":8.5,"relativehumidity_2m":82,"windspeed_10m":18.9},{"datetime":"2024-10-13T13:00:00.000+02:00","dcPower":1538.0761562598432,"power":1230.4609250078747,"sunTilt":31.17904416002473,"sunAzimuth":3.731196531846679,"temperature":9.4,"relativehumidity_2m":79,"windspeed_10m":19.1},{"datetime":"2024-10-13T14:00:00.000+02:00","dcPower":1689.2405941284853,"power":1351.3924753027884,"sunTilt":29.150649828823855,"sunAzimuth":20.774587486422366,"temperature":10.4,"relativehumidity_2m":75,"windspeed_10m":17.7},{"datetime":"2024-10-13T15:00:00.000+02:00","dcPower":1654.777719219827,"power":1323.8221753758617,"sunTilt":24.583621654336596,"sunAzimuth":36.642703763191776,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":17.6},{"datetime":"2024-10-13T16:00:00.000+02:00","dcPower":748.7985198938329,"power":599.0388159150664,"sunTilt":18.016365562424813,"sunAzimuth":50.96413031013026,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-13T17:00:00.000+02:00","dcPower":555.8420628793457,"power":444.67365030347656,"sunTilt":10.030663139779685,"sunAzimuth":63.89195411454739,"temperature":9.9,"relativehumidity_2m":79,"windspeed_10m":13.7},{"datetime":"2024-10-13T18:00:00.000+02:00","dcPower":230.90863388928312,"power":184.7269071114265,"sunTilt":1.1390561362362333,"sunAzimuth":75.8636886240363,"temperature":9.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-13T19:00:00.000+02:00","dcPower":31.697596783778167,"power":25.358077427022536,"sunTilt":-8.226960953264081,"sunAzimuth":87.43095436456028,"temperature":8.3,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-13T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.676940156857277,"sunAzimuth":99.2093472034457,"temperature":8.1,"relativehumidity_2m":87,"windspeed_10m":6.4},{"datetime":"2024-10-13T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.79858696849319,"sunAzimuth":111.90025906076745,"temperature":7.6,"relativehumidity_2m":90,"windspeed_10m":6.8},{"datetime":"2024-10-13T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.0781256664598,"sunAzimuth":126.3155777234557,"temperature":7.1,"relativehumidity_2m":90,"windspeed_10m":5},{"datetime":"2024-10-13T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.815632684929426,"sunAzimuth":143.2709019404984,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":2.6},{"datetime":"2024-10-14T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.11451301786215,"sunAzimuth":163.08983536166957,"temperature":6.3,"relativehumidity_2m":89,"windspeed_10m":2.4},{"datetime":"2024-10-14T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.138671848584934,"sunAzimuth":-175.2642016873541,"temperature":5.8,"relativehumidity_2m":89,"windspeed_10m":1.5},{"datetime":"2024-10-14T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.647478099904184,"sunAzimuth":-154.15814092455744,"temperature":5.3,"relativehumidity_2m":90,"windspeed_10m":4},{"datetime":"2024-10-14T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.1999672120048,"sunAzimuth":-135.52742842855076,"temperature":4.5,"relativehumidity_2m":93,"windspeed_10m":1.5},{"datetime":"2024-10-14T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.715244466780028,"sunAzimuth":-119.73450334119632,"temperature":4,"relativehumidity_2m":94,"windspeed_10m":4.5},{"datetime":"2024-10-14T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.01770469074795,"sunAzimuth":-106.15013533780943,"temperature":3.8,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.720095220219516,"sunAzimuth":-93.92913753819082,"temperature":4.2,"relativehumidity_2m":94,"windspeed_10m":5.8},{"datetime":"2024-10-14T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.283430715645694,"sunAzimuth":-82.30618839226909,"temperature":4.7,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.896654311121907,"sunAzimuth":-70.62246187023919,"temperature":5.1,"relativehumidity_2m":93,"windspeed_10m":4.3},{"datetime":"2024-10-14T09:00:00.000+02:00","dcPower":132.97359671714773,"power":106.37887737371818,"sunTilt":13.417455844515738,"sunAzimuth":-58.29261188464403,"temperature":5.6,"relativehumidity_2m":92,"windspeed_10m":3.8},{"datetime":"2024-10-14T10:00:00.000+02:00","dcPower":405.59211479841304,"power":324.4736918387305,"sunTilt":20.815136015137117,"sunAzimuth":-44.807755986987566,"temperature":6.7,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-14T11:00:00.000+02:00","dcPower":616.8979931513162,"power":493.518394521053,"sunTilt":26.543021174019792,"sunAzimuth":-29.83407952105961,"temperature":7.5,"relativehumidity_2m":85,"windspeed_10m":10.1},{"datetime":"2024-10-14T12:00:00.000+02:00","dcPower":544.0337398355801,"power":435.22699186846415,"sunTilt":30.022594925866507,"sunAzimuth":-13.427153142476177,"temperature":7.8,"relativehumidity_2m":87,"windspeed_10m":7.4},{"datetime":"2024-10-14T13:00:00.000+02:00","dcPower":757.6272986491891,"power":606.1018389193513,"sunTilt":30.804692641790414,"sunAzimuth":3.780636799023051,"temperature":8.7,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-14T14:00:00.000+02:00","dcPower":802.6332382442511,"power":642.1065905954009,"sunTilt":28.775019978770835,"sunAzimuth":20.742907009369926,"temperature":8.8,"relativehumidity_2m":83,"windspeed_10m":7.7},{"datetime":"2024-10-14T15:00:00.000+02:00","dcPower":727.2477881703447,"power":581.7982305362758,"sunTilt":24.217486258022436,"sunAzimuth":36.546087194880954,"temperature":8.8,"relativehumidity_2m":85,"windspeed_10m":6.5},{"datetime":"2024-10-14T16:00:00.000+02:00","dcPower":588.679937715913,"power":470.9439501727304,"sunTilt":17.664529576020236,"sunAzimuth":50.82342857831685,"temperature":8.7,"relativehumidity_2m":88,"windspeed_10m":5.2},{"datetime":"2024-10-14T17:00:00.000+02:00","dcPower":408.4315746861509,"power":326.7452597489207,"sunTilt":9.692668263612882,"sunAzimuth":63.7238891712367,"temperature":8.4,"relativehumidity_2m":90,"windspeed_10m":3.6},{"datetime":"2024-10-14T18:00:00.000+02:00","dcPower":189.55947192037354,"power":151.64757753629883,"sunTilt":0.8111983279457368,"sunAzimuth":75.67900710147994,"temperature":8.2,"relativehumidity_2m":91,"windspeed_10m":1.8},{"datetime":"2024-10-14T19:00:00.000+02:00","dcPower":31.73574416203751,"power":25.388595329630007,"sunTilt":-8.5501186243532,"sunAzimuth":87.23629074336492,"temperature":7.9,"relativehumidity_2m":93,"windspeed_10m":0.4},{"datetime":"2024-10-14T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.00178541748869,"sunAzimuth":99.01023762777291,"temperature":7.4,"relativehumidity_2m":94,"windspeed_10m":1.1},{"datetime":"2024-10-14T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.131935065748255,"sunAzimuth":111.70509440468732,"temperature":6.7,"relativehumidity_2m":96,"windspeed_10m":1.1},{"datetime":"2024-10-14T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.426113802819536,"sunAzimuth":126.14148133580082,"temperature":5.8,"relativehumidity_2m":98,"windspeed_10m":0.4},{"datetime":"2024-10-14T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.180713378529184,"sunAzimuth":143.15089515757862,"temperature":5,"relativehumidity_2m":100,"windspeed_10m":0.7},{"datetime":"2024-10-15T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.49001057641694,"sunAzimuth":163.07102046565072,"temperature":4.3,"relativehumidity_2m":100,"windspeed_10m":1.4},{"datetime":"2024-10-15T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.506419588733614,"sunAzimuth":-175.15128768425117,"temperature":3.8,"relativehumidity_2m":100,"windspeed_10m":1.9},{"datetime":"2024-10-15T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.988103658491234,"sunAzimuth":-153.9352385869432,"temperature":3.2,"relativehumidity_2m":100,"windspeed_10m":2.6},{"datetime":"2024-10-15T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.50640211284843,"sunAzimuth":-135.24690137263536,"temperature":2.4,"relativehumidity_2m":100,"windspeed_10m":2.9},{"datetime":"2024-10-15T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.992601433814595,"sunAzimuth":-119.43676351875516,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.2},{"datetime":"2024-10-15T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.276123004228086,"sunAzimuth":-105.85422295783262,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.970257710966642,"sunAzimuth":-93.64119828341282,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.5352276078301,"sunAzimuth":-82.02738904900566,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.63422819396966,"sunAzimuth":-70.35370359566531,"temperature":2.3,"relativehumidity_2m":100,"windspeed_10m":4},{"datetime":"2024-10-15T09:00:00.000+02:00","dcPower":160.7609025328025,"power":128.60872202624202,"sunTilt":13.136374944499462,"sunAzimuth":-58.037763824819606,"temperature":3.4,"relativehumidity_2m":98,"windspeed_10m":5},{"datetime":"2024-10-15T10:00:00.000+02:00","dcPower":455.71724945435693,"power":364.5737995634856,"sunTilt":20.50908861823578,"sunAzimuth":-44.57657122307415,"temperature":4.8,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-15T11:00:00.000+02:00","dcPower":687.0836404217806,"power":549.6669123374245,"sunTilt":26.20926450679345,"sunAzimuth":-29.644024765315514,"temperature":6.3,"relativehumidity_2m":91,"windspeed_10m":7.8},{"datetime":"2024-10-15T12:00:00.000+02:00","dcPower":1904.2142151339715,"power":1523.3713721071772,"sunTilt":29.66446288060627,"sunAzimuth":-13.300572957692536,"temperature":8,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-15T13:00:00.000+02:00","dcPower":2323.589968212759,"power":1858.8719745702072,"sunTilt":30.43234097439692,"sunAzimuth":3.8270522061418095,"temperature":9.7,"relativehumidity_2m":84,"windspeed_10m":10.9},{"datetime":"2024-10-15T14:00:00.000+02:00","dcPower":2439.6345315420713,"power":1951.7076252336572,"sunTilt":28.401790922595836,"sunAzimuth":20.709329389098386,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":11.9},{"datetime":"2024-10-15T15:00:00.000+02:00","dcPower":2183.596181552923,"power":1746.8769452423385,"sunTilt":23.853972526435296,"sunAzimuth":36.448429487078144,"temperature":11.2,"relativehumidity_2m":81,"windspeed_10m":11.9},{"datetime":"2024-10-15T16:00:00.000+02:00","dcPower":610.6314730323843,"power":488.50517842590745,"sunTilt":17.315445485866938,"sunAzimuth":50.682117672382816,"temperature":11,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-15T17:00:00.000+02:00","dcPower":452.43008429729906,"power":361.9440674378393,"sunTilt":9.35753798215806,"sunAzimuth":63.55531186506398,"temperature":10.6,"relativehumidity_2m":82,"windspeed_10m":11.1},{"datetime":"2024-10-15T18:00:00.000+02:00","dcPower":246.26795036997265,"power":197.01436029597812,"sunTilt":0.48632726290863104,"sunAzimuth":75.49368820866877,"temperature":9.7,"relativehumidity_2m":85,"windspeed_10m":12.1},{"datetime":"2024-10-15T19:00:00.000+02:00","dcPower":52.82473341173196,"power":42.259786729385574,"sunTilt":-8.870162619560801,"sunAzimuth":87.04069822248259,"temperature":8.5,"relativehumidity_2m":88,"windspeed_10m":13.6},{"datetime":"2024-10-15T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.323410184499085,"sunAzimuth":98.80974345652376,"temperature":7.6,"relativehumidity_2m":91,"windspeed_10m":14.3},{"datetime":"2024-10-15T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.462022150796383,"sunAzimuth":111.50791010170794,"temperature":7.2,"relativehumidity_2m":91,"windspeed_10m":13.9},{"datetime":"2024-10-15T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.770938188604354,"sunAzimuth":125.96460338334036,"temperature":7,"relativehumidity_2m":91,"windspeed_10m":12.9},{"datetime":"2024-10-15T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.54293600102064,"sunAzimuth":143.02752222319543,"temperature":6.8,"relativehumidity_2m":89,"windspeed_10m":12.7},{"datetime":"2024-10-16T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.86312551477473,"sunAzimuth":163.04903976266138,"temperature":6.7,"relativehumidity_2m":86,"windspeed_10m":13.8},{"datetime":"2024-10-16T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.87221785059982,"sunAzimuth":-175.04051110279474,"temperature":6.8,"relativehumidity_2m":83,"windspeed_10m":15.6},{"datetime":"2024-10-16T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.32707330350441,"sunAzimuth":-153.71374265034802,"temperature":6.8,"relativehumidity_2m":80,"windspeed_10m":16.5},{"datetime":"2024-10-16T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.81147561550361,"sunAzimuth":-134.96784827714956,"temperature":7,"relativehumidity_2m":78,"windspeed_10m":16.1},{"datetime":"2024-10-16T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.26895363243685,"sunAzimuth":-119.1408914506387,"temperature":7.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-16T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.53389843309148,"sunAzimuth":-105.56056815727264,"temperature":7.4,"relativehumidity_2m":75,"windspeed_10m":14.3},{"datetime":"2024-10-16T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.220082554609817,"sunAzimuth":-93.355861910257,"temperature":7.4,"relativehumidity_2m":73,"windspeed_10m":14.3},{"datetime":"2024-10-16T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.786895768698711,"sunAzimuth":-81.75154101774274,"temperature":7.3,"relativehumidity_2m":71,"windspeed_10m":14.7},{"datetime":"2024-10-16T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.371849783596975,"sunAzimuth":-70.08829035405888,"temperature":7.2,"relativehumidity_2m":70,"windspeed_10m":14.8},{"datetime":"2024-10-16T09:00:00.000+02:00","dcPower":120.67880138508733,"power":96.54304110806987,"sunTilt":12.855424254104108,"sunAzimuth":-57.78670104663053,"temperature":9.8,"relativehumidity_2m":72,"windspeed_10m":17},{"datetime":"2024-10-16T10:00:00.000+02:00","dcPower":349.2545941544614,"power":279.40367532356913,"sunTilt":20.203454185088123,"sunAzimuth":-44.34957719582011,"temperature":10.2,"relativehumidity_2m":75,"windspeed_10m":16.5},{"datetime":"2024-10-16T11:00:00.000+02:00","dcPower":549.0777204410166,"power":439.2621763528133,"sunTilt":25.87641150088954,"sunAzimuth":-29.45832187587005,"temperature":10.7,"relativehumidity_2m":77,"windspeed_10m":16.2},{"datetime":"2024-10-16T12:00:00.000+02:00","dcPower":1293.9533240813814,"power":1035.1626592651053,"sunTilt":29.30785715204484,"sunAzimuth":-13.177984892004138,"temperature":11.6,"relativehumidity_2m":77,"windspeed_10m":15.7},{"datetime":"2024-10-16T13:00:00.000+02:00","dcPower":1629.0729841502784,"power":1303.258387320223,"sunTilt":30.062101719417093,"sunAzimuth":3.870407893111919,"temperature":12.5,"relativehumidity_2m":75,"windspeed_10m":15.3},{"datetime":"2024-10-16T14:00:00.000+02:00","dcPower":1733.765780840024,"power":1387.0126246720192,"sunTilt":28.03108091352568,"sunAzimuth":20.673818848950724,"temperature":13.2,"relativehumidity_2m":75,"windspeed_10m":14.8},{"datetime":"2024-10-16T15:00:00.000+02:00","dcPower":1540.2873204227294,"power":1232.2298563381837,"sunTilt":23.49320409080551,"sunAzimuth":36.34969840216598,"temperature":13.5,"relativehumidity_2m":77,"windspeed_10m":14.3},{"datetime":"2024-10-16T16:00:00.000+02:00","dcPower":670.2174910745355,"power":536.1739928596284,"sunTilt":16.969240740198778,"sunAzimuth":50.54017502787197,"temperature":13.5,"relativehumidity_2m":80,"windspeed_10m":13.7},{"datetime":"2024-10-16T17:00:00.000+02:00","dcPower":457.92616067692046,"power":366.3409285415364,"sunTilt":9.025401762418719,"sunAzimuth":63.386211071389155,"temperature":13.3,"relativehumidity_2m":82,"windspeed_10m":13.2},{"datetime":"2024-10-16T18:00:00.000+02:00","dcPower":227.80720186977683,"power":182.24576149582148,"sunTilt":0.16457346744538273,"sunAzimuth":75.30773046735078,"temperature":13,"relativehumidity_2m":82,"windspeed_10m":13},{"datetime":"2024-10-16T19:00:00.000+02:00","dcPower":46.84238505374372,"power":37.47390804299498,"sunTilt":-9.186961271997145,"sunAzimuth":86.84418120594421,"temperature":12.6,"relativehumidity_2m":81,"windspeed_10m":12.7},{"datetime":"2024-10-16T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.64168080965683,"sunAzimuth":98.60786901950698,"temperature":12.2,"relativehumidity_2m":80,"windspeed_10m":12.2},{"datetime":"2024-10-16T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.788711717705176,"sunAzimuth":111.30870010109864,"temperature":12,"relativehumidity_2m":80,"windspeed_10m":11.8},{"datetime":"2024-10-16T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.112460277357016,"sunAzimuth":125.78490981561183,"temperature":11.8,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-16T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.9021656490531,"sunAzimuth":142.90069940402603,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":10.2},{"datetime":"2024-10-17T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.23373820466217,"sunAzimuth":163.02375644913673,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":9.4},{"datetime":"2024-10-17T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-48.2359718752176,"sunAzimuth":-174.9320340577077,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.6},{"datetime":"2024-10-17T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.66431793404074,"sunAzimuth":-153.4938195445769,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.2},{"datetime":"2024-10-17T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.115139405653565,"sunAzimuth":-134.69043107615582,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.9},{"datetime":"2024-10-17T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.54426550294801,"sunAzimuth":-118.84703704658551,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.79099983796868,"sunAzimuth":-105.2693069294498,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.469536195186127,"sunAzimuth":-93.07325245162968,"temperature":11.6,"relativehumidity_2m":82,"windspeed_10m":7.4},{"datetime":"2024-10-17T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-5.038393916921545,"sunAzimuth":-81.47875856041325,"temperature":11.4,"relativehumidity_2m":83,"windspeed_10m":7.3},{"datetime":"2024-10-17T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.109572239802723,"sunAzimuth":-69.8263270263417,"temperature":11.5,"relativehumidity_2m":83,"windspeed_10m":7.4},{"datetime":"2024-10-17T09:00:00.000+02:00","dcPower":109.40847975459633,"power":87.52678380367706,"sunTilt":12.574671747325786,"sunAzimuth":-57.53951661964237,"temperature":11.9,"relativehumidity_2m":83,"windspeed_10m":8},{"datetime":"2024-10-17T10:00:00.000+02:00","dcPower":325.9211402075719,"power":260.73691216605755,"sunTilt":19.89831645004539,"sunAzimuth":-44.1268501705138,"temperature":12.5,"relativehumidity_2m":82,"windspeed_10m":9.7},{"datetime":"2024-10-17T11:00:00.000+02:00","dcPower":508.9101858471262,"power":407.12814867770095,"sunTilt":25.54455964459646,"sunAzimuth":-29.277026273264024,"temperature":13.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-17T12:00:00.000+02:00","dcPower":894.0445396905282,"power":715.2356317524226,"sunTilt":28.952884649607554,"sunAzimuth":-13.059427258390235,"temperature":13.5,"relativehumidity_2m":83,"windspeed_10m":10.7},{"datetime":"2024-10-17T13:00:00.000+02:00","dcPower":951.2372373763384,"power":760.9897899010707,"sunTilt":29.694087805098004,"sunAzimuth":3.9106711264578404,"temperature":13.8,"relativehumidity_2m":85,"windspeed_10m":10.8},{"datetime":"2024-10-17T14:00:00.000+02:00","dcPower":939.5640180555158,"power":751.6512144444127,"sunTilt":27.663008228484166,"sunAzimuth":20.63634185579284,"temperature":14.2,"relativehumidity_2m":86,"windspeed_10m":10.5},{"datetime":"2024-10-17T15:00:00.000+02:00","dcPower":879.0007445936827,"power":703.2005956749463,"sunTilt":23.13530429931852,"sunAzimuth":36.24986394147597,"temperature":14.6,"relativehumidity_2m":87,"windspeed_10m":9.8},{"datetime":"2024-10-17T16:00:00.000+02:00","dcPower":651.4897418815121,"power":521.1917935052097,"sunTilt":16.626042266391202,"sunAzimuth":50.39758024284303,"temperature":14.9,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-17T17:00:00.000+02:00","dcPower":469.9569459149162,"power":375.965556731933,"sunTilt":8.696388379725821,"sunAzimuth":63.21657784217465,"temperature":15.1,"relativehumidity_2m":89,"windspeed_10m":8.7},{"datetime":"2024-10-17T18:00:00.000+02:00","dcPower":231.32779574813136,"power":185.0622365985051,"sunTilt":-0.15393334821361332,"sunAzimuth":75.12113476396313,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-17T19:00:00.000+02:00","dcPower":41.347251661047395,"power":33.077801328837914,"sunTilt":-9.500383797971471,"sunAzimuth":86.64674682587622,"temperature":14.6,"relativehumidity_2m":92,"windspeed_10m":6.7},{"datetime":"2024-10-17T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.956464510241965,"sunAzimuth":98.40462190157557,"temperature":14.3,"relativehumidity_2m":93,"windspeed_10m":5.9},{"datetime":"2024-10-17T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-28.11186795253563,"sunAzimuth":111.10746217430679,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":5.4},{"datetime":"2024-10-17T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.45054181856139,"sunAzimuth":125.60237057929713,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":4.6},{"datetime":"2024-10-17T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.2582671659911,"sunAzimuth":142.77034580724174,"temperature":14.2,"relativehumidity_2m":95,"windspeed_10m":4.4}],[{"datetime":"2024-10-06T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.03892891605494,"sunAzimuth":163.14263622624128,"temperature":7,"relativehumidity_2m":88,"windspeed_10m":7.9},{"datetime":"2024-10-06T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.13778324543035,"sunAzimuth":-176.22585898864278,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":6.8},{"datetime":"2024-10-06T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.87117274960936,"sunAzimuth":-155.9729639229445,"temperature":6,"relativehumidity_2m":91,"windspeed_10m":5.9},{"datetime":"2024-10-06T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.70531092777589,"sunAzimuth":-137.8059489226708,"temperature":5.5,"relativehumidity_2m":92,"windspeed_10m":5.1},{"datetime":"2024-10-06T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.46443142893699,"sunAzimuth":-122.16602054266892,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.2},{"datetime":"2024-10-06T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-20.930765133481234,"sunAzimuth":-108.58249513077881,"temperature":4.9,"relativehumidity_2m":93,"windspeed_10m":5.8},{"datetime":"2024-10-06T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.710347315474053,"sunAzimuth":-96.31140508589108,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.5},{"datetime":"2024-10-06T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.2689443207496223,"sunAzimuth":-84.62890292197706,"temperature":5.3,"relativehumidity_2m":92,"windspeed_10m":7.6},{"datetime":"2024-10-06T08:00:00.000+02:00","dcPower":11.268313449099201,"power":9.01465075927936,"sunTilt":6.991906328571172,"sunAzimuth":-72.87999206290318,"temperature":5.5,"relativehumidity_2m":91,"windspeed_10m":8.4},{"datetime":"2024-10-06T09:00:00.000+02:00","dcPower":232.86769125035775,"power":186.2941530002862,"sunTilt":15.663160391528187,"sunAzimuth":-60.45596163553978,"temperature":6.3,"relativehumidity_2m":90,"windspeed_10m":9.3},{"datetime":"2024-10-06T10:00:00.000+02:00","dcPower":460.35082488685083,"power":368.2806599094807,"sunTilt":23.268816289666535,"sunAzimuth":-46.79827360798693,"temperature":8,"relativehumidity_2m":85,"windspeed_10m":11.6},{"datetime":"2024-10-06T11:00:00.000+02:00","dcPower":672.3552091335482,"power":537.8841673068385,"sunTilt":29.234287239795027,"sunAzimuth":-31.503805204051176,"temperature":9.5,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-06T12:00:00.000+02:00","dcPower":1681.9915426189737,"power":1345.593234095179,"sunTilt":32.93002248275174,"sunAzimuth":-14.578212396799534,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":16.3},{"datetime":"2024-10-06T13:00:00.000+02:00","dcPower":1774.549862136572,"power":1419.6398897092577,"sunTilt":33.84613522696556,"sunAzimuth":3.3037874055175505,"temperature":10.9,"relativehumidity_2m":82,"windspeed_10m":16.9},{"datetime":"2024-10-06T14:00:00.000+02:00","dcPower":1689.4756968481113,"power":1351.580557478489,"sunTilt":31.83736693728352,"sunAzimuth":20.94669333759787,"temperature":12.6,"relativehumidity_2m":74,"windspeed_10m":17.1},{"datetime":"2024-10-06T15:00:00.000+02:00","dcPower":1067.9359341691359,"power":854.3487473353088,"sunTilt":27.209548486852757,"sunAzimuth":37.29302345489315,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":17.8},{"datetime":"2024-10-06T16:00:00.000+02:00","dcPower":821.9702122637689,"power":657.5761698110151,"sunTilt":20.54547686442047,"sunAzimuth":51.93430385037965,"temperature":13.1,"relativehumidity_2m":73,"windspeed_10m":17.8},{"datetime":"2024-10-06T17:00:00.000+02:00","dcPower":774.5516286126806,"power":619.6413028901445,"sunTilt":12.4658413410018,"sunAzimuth":65.05541740712634,"temperature":12.6,"relativehumidity_2m":72,"windspeed_10m":15.3},{"datetime":"2024-10-06T18:00:00.000+02:00","dcPower":412.058868933531,"power":329.6470951468248,"sunTilt":3.5065849097251456,"sunAzimuth":77.13919140741508,"temperature":11.9,"relativehumidity_2m":76,"windspeed_10m":14.8},{"datetime":"2024-10-06T19:00:00.000+02:00","dcPower":82.78007183053384,"power":66.22405746442708,"sunTilt":-5.8888854618235795,"sunAzimuth":88.76774476136781,"temperature":11.1,"relativehumidity_2m":79,"windspeed_10m":15.1},{"datetime":"2024-10-06T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.324219942418523,"sunAzimuth":100.56460329657087,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.2},{"datetime":"2024-10-06T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.385415066790006,"sunAzimuth":113.21108724923529,"temperature":10.3,"relativehumidity_2m":81,"windspeed_10m":13.4},{"datetime":"2024-10-06T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.56528997863786,"sunAzimuth":127.45995077522508,"temperature":10.3,"relativehumidity_2m":83,"windspeed_10m":13.4},{"datetime":"2024-10-06T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.19130152908581,"sunAzimuth":144.02419079232183,"temperature":10.7,"relativehumidity_2m":83,"windspeed_10m":13},{"datetime":"2024-10-07T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.4292477470776,"sunAzimuth":163.14429087891105,"temperature":11,"relativehumidity_2m":83,"windspeed_10m":13.6},{"datetime":"2024-10-07T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.517749495901406,"sunAzimuth":-176.10136944486192,"temperature":11,"relativehumidity_2m":87,"windspeed_10m":11.2},{"datetime":"2024-10-07T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.22254930467857,"sunAzimuth":-155.74445709329385,"temperature":10.9,"relativehumidity_2m":91,"windspeed_10m":9.8},{"datetime":"2024-10-07T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.020882886444426,"sunAzimuth":-137.5192133151141,"temperature":11,"relativehumidity_2m":93,"windspeed_10m":8.7},{"datetime":"2024-10-07T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.748563322135755,"sunAzimuth":-121.8586066543482,"temperature":11.4,"relativehumidity_2m":94,"windspeed_10m":8.9},{"datetime":"2024-10-07T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.193270783038106,"sunAzimuth":-108.27337691467278,"temperature":11.5,"relativehumidity_2m":95,"windspeed_10m":11},{"datetime":"2024-10-07T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.962117012521006,"sunAzimuth":-96.00713008699226,"temperature":12,"relativehumidity_2m":94,"windspeed_10m":8.4},{"datetime":"2024-10-07T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.520439907672165,"sunAzimuth":-84.33068309080377,"temperature":12.2,"relativehumidity_2m":95,"windspeed_10m":9.3},{"datetime":"2024-10-07T08:00:00.000+02:00","dcPower":4.997945294591559,"power":3.9983562356732474,"sunTilt":6.730829735879827,"sunAzimuth":-72.58838243599898,"temperature":12.7,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-07T09:00:00.000+02:00","dcPower":102.01332391139826,"power":81.61065912911862,"sunTilt":15.383293627086166,"sunAzimuth":-60.174375329765816,"temperature":13.3,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-07T10:00:00.000+02:00","dcPower":408.4884640831041,"power":326.79077126648326,"sunTilt":22.96230371458641,"sunAzimuth":-46.53655309716923,"temperature":14.4,"relativehumidity_2m":91,"windspeed_10m":8.9},{"datetime":"2024-10-07T11:00:00.000+02:00","dcPower":378.1151467220305,"power":302.4921173776244,"sunTilt":28.896671975172765,"sunAzimuth":-31.281195510232457,"temperature":15.2,"relativehumidity_2m":91,"windspeed_10m":10.9},{"datetime":"2024-10-07T12:00:00.000+02:00","dcPower":734.6436488825714,"power":587.7149191060571,"sunTilt":32.56343562699629,"sunAzimuth":-14.421329305847635,"temperature":16,"relativehumidity_2m":89,"windspeed_10m":8.4},{"datetime":"2024-10-07T13:00:00.000+02:00","dcPower":1364.6506150490802,"power":1091.7204920392642,"sunTilt":33.46089993111447,"sunAzimuth":3.3732496911031458,"temperature":17,"relativehumidity_2m":82,"windspeed_10m":10.5},{"datetime":"2024-10-07T14:00:00.000+02:00","dcPower":1502.5040978903205,"power":1202.0032783122565,"sunTilt":31.448234246769605,"sunAzimuth":20.927108911856653,"temperature":18,"relativehumidity_2m":75,"windspeed_10m":9.8},{"datetime":"2024-10-07T15:00:00.000+02:00","dcPower":1019.1998220923691,"power":815.3598576738954,"sunTilt":26.828539273003113,"sunAzimuth":37.20261442198752,"temperature":18.6,"relativehumidity_2m":70,"windspeed_10m":5.8},{"datetime":"2024-10-07T16:00:00.000+02:00","dcPower":843.1514040310797,"power":674.5211232248638,"sunTilt":20.17798028996772,"sunAzimuth":51.797067137147856,"temperature":18.9,"relativehumidity_2m":70,"windspeed_10m":7.4},{"datetime":"2024-10-07T17:00:00.000+02:00","dcPower":758.8576567525531,"power":607.0861254020425,"sunTilt":12.11146577521795,"sunAzimuth":64.89046144901918,"temperature":18.4,"relativehumidity_2m":72,"windspeed_10m":7.2},{"datetime":"2024-10-07T18:00:00.000+02:00","dcPower":346.21701930163505,"power":276.973615441308,"sunTilt":3.1615292467832945,"sunAzimuth":76.95875071433478,"temperature":17.4,"relativehumidity_2m":74,"windspeed_10m":9.5},{"datetime":"2024-10-07T19:00:00.000+02:00","dcPower":76.34921188156889,"power":61.079369505255116,"sunTilt":-6.2300935503595385,"sunAzimuth":88.57949984494998,"temperature":16.4,"relativehumidity_2m":79,"windspeed_10m":7.6},{"datetime":"2024-10-07T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.667810875617224,"sunAzimuth":100.37506201699385,"temperature":15.4,"relativehumidity_2m":87,"windspeed_10m":9.2},{"datetime":"2024-10-07T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.737727405113922,"sunAzimuth":113.02960837119129,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":10.9},{"datetime":"2024-10-07T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.93153539182831,"sunAzimuth":127.30408938398604,"temperature":14.5,"relativehumidity_2m":94,"windspeed_10m":10.7},{"datetime":"2024-10-07T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.572641175501424,"sunAzimuth":143.925225436442,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":11.6},{"datetime":"2024-10-08T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.818121034022454,"sunAzimuth":163.14384998115503,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":15.8},{"datetime":"2024-10-08T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.8965146817323,"sunAzimuth":-175.97781528741945,"temperature":14.8,"relativehumidity_2m":93,"windspeed_10m":14},{"datetime":"2024-10-08T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.572826067170936,"sunAzimuth":-155.51612909180187,"temperature":15.4,"relativehumidity_2m":94,"windspeed_10m":11.2},{"datetime":"2024-10-08T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.33548533853669,"sunAzimuth":-137.23273090041528,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":12.2},{"datetime":"2024-10-08T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.031970973409337,"sunAzimuth":-121.55190085899577,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":11.2},{"datetime":"2024-10-08T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.45536424486834,"sunAzimuth":-107.96544161465735,"temperature":15.8,"relativehumidity_2m":94,"windspeed_10m":10},{"datetime":"2024-10-08T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.213784351950519,"sunAzimuth":-95.70446004731738,"temperature":15.8,"relativehumidity_2m":93,"windspeed_10m":10.8},{"datetime":"2024-10-08T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.772091353585381,"sunAzimuth":-84.03447848078322,"temperature":15.7,"relativehumidity_2m":93,"windspeed_10m":10.5},{"datetime":"2024-10-08T08:00:00.000+02:00","dcPower":3.9570941862162403,"power":3.1656753489729925,"sunTilt":6.469429830924286,"sunAzimuth":-72.29923977528891,"temperature":15.7,"relativehumidity_2m":90,"windspeed_10m":10.2},{"datetime":"2024-10-08T09:00:00.000+02:00","dcPower":206.74517171719188,"power":165.3961373737535,"sunTilt":15.10307050684314,"sunAzimuth":-59.895772492079,"temperature":15.7,"relativehumidity_2m":89,"windspeed_10m":8.3},{"datetime":"2024-10-08T10:00:00.000+02:00","dcPower":552.8014529356424,"power":442.2411623485139,"sunTilt":22.655587204347487,"sunAzimuth":-46.27834028428006,"temperature":16.3,"relativehumidity_2m":86,"windspeed_10m":10.6},{"datetime":"2024-10-08T11:00:00.000+02:00","dcPower":817.8807593131021,"power":654.3046074504817,"sunTilt":28.55922255887939,"sunAzimuth":-31.06241498925654,"temperature":17,"relativehumidity_2m":85,"windspeed_10m":9},{"datetime":"2024-10-08T12:00:00.000+02:00","dcPower":1175.4689911230503,"power":940.3751928984402,"sunTilt":32.19754822667087,"sunAzimuth":-14.26805544520534,"temperature":17.6,"relativehumidity_2m":82,"windspeed_10m":13.1},{"datetime":"2024-10-08T13:00:00.000+02:00","dcPower":1568.6417485623892,"power":1254.9133988499116,"sunTilt":33.07688907994966,"sunAzimuth":3.4399896103944245,"temperature":18.3,"relativehumidity_2m":73,"windspeed_10m":12.7},{"datetime":"2024-10-08T14:00:00.000+02:00","dcPower":1421.4294036606193,"power":1137.1435229284955,"sunTilt":31.060677746554727,"sunAzimuth":20.90593811884074,"temperature":18.2,"relativehumidity_2m":71,"windspeed_10m":13.2},{"datetime":"2024-10-08T15:00:00.000+02:00","dcPower":1147.3071758445456,"power":917.8457406756365,"sunTilt":26.44928138282813,"sunAzimuth":37.11144846293623,"temperature":18.1,"relativehumidity_2m":71,"windspeed_10m":13.3},{"datetime":"2024-10-08T16:00:00.000+02:00","dcPower":829.3893256086282,"power":663.5114604869026,"sunTilt":19.81233269039211,"sunAzimuth":51.65943401571543,"temperature":17.4,"relativehumidity_2m":73,"windspeed_10m":9.2},{"datetime":"2024-10-08T17:00:00.000+02:00","dcPower":592.2614991449287,"power":473.80919931594303,"sunTilt":11.759033809196055,"sunAzimuth":64.72512518734406,"temperature":16.9,"relativehumidity_2m":75,"windspeed_10m":6.3},{"datetime":"2024-10-08T18:00:00.000+02:00","dcPower":338.1808208981102,"power":270.54465671848817,"sunTilt":2.818529310708217,"sunAzimuth":76.77774147088243,"temperature":16,"relativehumidity_2m":75,"windspeed_10m":8.6},{"datetime":"2024-10-08T19:00:00.000+02:00","dcPower":76.72176162631511,"power":61.3774093010521,"sunTilt":-6.569128186596377,"sunAzimuth":88.39036262615889,"temperature":15,"relativehumidity_2m":82,"windspeed_10m":5.8},{"datetime":"2024-10-08T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.00913432264249,"sunAzimuth":100.18418560559792,"temperature":14.5,"relativehumidity_2m":89,"windspeed_10m":4.7},{"datetime":"2024-10-08T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.087746753502067,"sunAzimuth":112.84624373263145,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":8.4},{"datetime":"2024-10-08T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.29558919415396,"sunAzimuth":127.1457755347392,"temperature":14,"relativehumidity_2m":88,"windspeed_10m":8.6},{"datetime":"2024-10-08T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.95205668980692,"sunAzimuth":143.82353531827584,"temperature":13,"relativehumidity_2m":91,"windspeed_10m":7.2},{"datetime":"2024-10-09T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.20543351779722,"sunAzimuth":163.1411836959831,"temperature":12.6,"relativehumidity_2m":92,"windspeed_10m":7.8},{"datetime":"2024-10-09T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.27398595213309,"sunAzimuth":-175.8553370630724,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.5},{"datetime":"2024-10-09T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.92193289241474,"sunAzimuth":-155.28812310014723,"temperature":12.7,"relativehumidity_2m":92,"windspeed_10m":10.5},{"datetime":"2024-10-09T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.64906836752005,"sunAzimuth":-136.94664669208646,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":12.3},{"datetime":"2024-10-09T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.314619206373006,"sunAzimuth":-121.24604384005964,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.2},{"datetime":"2024-10-09T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.71701795569338,"sunAzimuth":-107.65882183814558,"temperature":13.2,"relativehumidity_2m":90,"windspeed_10m":12.9},{"datetime":"2024-10-09T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.465322674764078,"sunAzimuth":-95.40351983003244,"temperature":13.3,"relativehumidity_2m":89,"windspeed_10m":13.5},{"datetime":"2024-10-09T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.023867147662328,"sunAzimuth":-83.74040781223535,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":13.2},{"datetime":"2024-10-09T08:00:00.000+02:00","dcPower":0.9989938192491249,"power":0.7991950553992999,"sunTilt":6.207747953736028,"sunAzimuth":-72.01267721735641,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":11.8},{"datetime":"2024-10-09T09:00:00.000+02:00","dcPower":53.8969992305709,"power":43.117599384456724,"sunTilt":14.82254636430179,"sunAzimuth":-59.62025856421435,"temperature":12.8,"relativehumidity_2m":93,"windspeed_10m":11.8},{"datetime":"2024-10-09T10:00:00.000+02:00","dcPower":171.5134955209038,"power":137.21079641672304,"sunTilt":22.348738640203063,"sunAzimuth":-46.023727432629336,"temperature":13.1,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T11:00:00.000+02:00","dcPower":349.730317681861,"power":279.7842541454888,"sunTilt":28.22202694317921,"sunAzimuth":-30.847536638720733,"temperature":13.7,"relativehumidity_2m":92,"windspeed_10m":12.8},{"datetime":"2024-10-09T12:00:00.000+02:00","dcPower":518.1643251136576,"power":414.5314600909261,"sunTilt":31.832460658762763,"sunAzimuth":-14.118446086753368,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":12},{"datetime":"2024-10-09T13:00:00.000+02:00","dcPower":477.84371999495954,"power":382.2749759959677,"sunTilt":32.69421193791607,"sunAzimuth":3.503957762689472,"temperature":14.2,"relativehumidity_2m":90,"windspeed_10m":11.5},{"datetime":"2024-10-09T14:00:00.000+02:00","dcPower":356.3376608266884,"power":285.07012866135074,"sunTilt":30.674814714273392,"sunAzimuth":20.883130296493302,"temperature":13.9,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T15:00:00.000+02:00","dcPower":200.71128030166,"power":160.569024241328,"sunTilt":26.07189945210854,"sunAzimuth":37.01947906146245,"temperature":13.8,"relativehumidity_2m":92,"windspeed_10m":13.8},{"datetime":"2024-10-09T16:00:00.000+02:00","dcPower":156.27682051714766,"power":125.02145641371813,"sunTilt":19.448663917806936,"sunAzimuth":51.521368716140636,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":14.5},{"datetime":"2024-10-09T17:00:00.000+02:00","dcPower":104.42053437220898,"power":83.53642749776719,"sunTilt":11.408678227135676,"sunAzimuth":64.55938454107346,"temperature":13.5,"relativehumidity_2m":90,"windspeed_10m":15.8},{"datetime":"2024-10-09T18:00:00.000+02:00","dcPower":62.23906027081793,"power":49.791248216654346,"sunTilt":2.4777195309842384,"sunAzimuth":76.59614824640437,"temperature":13.4,"relativehumidity_2m":92,"windspeed_10m":13.4},{"datetime":"2024-10-09T19:00:00.000+02:00","dcPower":13.94786105197898,"power":11.158288841583186,"sunTilt":-6.905853552820109,"sunAzimuth":88.20032142925027,"temperature":13.6,"relativehumidity_2m":93,"windspeed_10m":14.5},{"datetime":"2024-10-09T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.348052794342024,"sunAzimuth":99.99195941586059,"temperature":13.7,"relativehumidity_2m":94,"windspeed_10m":15.3},{"datetime":"2024-10-09T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.43533401280622,"sunAzimuth":112.66096568522038,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":15.5},{"datetime":"2024-10-09T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.657312701408955,"sunAzimuth":126.98495418142949,"temperature":14.2,"relativehumidity_2m":93,"windspeed_10m":14.3},{"datetime":"2024-10-09T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.32941598688323,"sunAzimuth":143.71902473598436,"temperature":14.8,"relativehumidity_2m":90,"windspeed_10m":17.6},{"datetime":"2024-10-10T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.59106942107243,"sunAzimuth":163.13616076353475,"temperature":15.3,"relativehumidity_2m":89,"windspeed_10m":16.9},{"datetime":"2024-10-10T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.65007030458014,"sunAzimuth":-175.73407816523158,"temperature":15,"relativehumidity_2m":92,"windspeed_10m":15.1},{"datetime":"2024-10-10T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.26979987634531,"sunAzimuth":-155.060585251648,"temperature":14.9,"relativehumidity_2m":91,"windspeed_10m":15.3},{"datetime":"2024-10-10T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.96158240518052,"sunAzimuth":-136.66110792848332,"temperature":13.8,"relativehumidity_2m":97,"windspeed_10m":18.8},{"datetime":"2024-10-10T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.59647297796838,"sunAzimuth":-120.94117758387233,"temperature":12.4,"relativehumidity_2m":94,"windspeed_10m":25.6},{"datetime":"2024-10-10T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.97820409915596,"sunAzimuth":-107.35365077473413,"temperature":12.1,"relativehumidity_2m":92,"windspeed_10m":22.8},{"datetime":"2024-10-10T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.716704637744286,"sunAzimuth":-95.1044343497123,"temperature":12,"relativehumidity_2m":95,"windspeed_10m":19.5},{"datetime":"2024-10-10T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.275734703378654,"sunAzimuth":-83.4485894065161,"temperature":11.8,"relativehumidity_2m":93,"windspeed_10m":17.2},{"datetime":"2024-10-10T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":5.945826827444097,"sunAzimuth":-71.72880701980833,"temperature":12.1,"relativehumidity_2m":95,"windspeed_10m":16.3},{"datetime":"2024-10-10T09:00:00.000+02:00","dcPower":27.532420914402955,"power":22.025936731522364,"sunTilt":14.541778076840487,"sunAzimuth":-59.34793757422427,"temperature":12.1,"relativehumidity_2m":94,"windspeed_10m":14.7},{"datetime":"2024-10-10T10:00:00.000+02:00","dcPower":80.96158099137317,"power":64.76926479309854,"sunTilt":22.041831409435048,"sunAzimuth":-45.7728048820313,"temperature":12.2,"relativehumidity_2m":92,"windspeed_10m":15},{"datetime":"2024-10-10T11:00:00.000+02:00","dcPower":150.5237904203776,"power":120.41903233630208,"sunTilt":27.885174335468335,"sunAzimuth":-30.63663127573816,"temperature":12.5,"relativehumidity_2m":92,"windspeed_10m":15.7},{"datetime":"2024-10-10T12:00:00.000+02:00","dcPower":698.2747101395488,"power":558.619768111639,"sunTilt":31.46827419380231,"sunAzimuth":-13.972554382839954,"temperature":12.8,"relativehumidity_2m":89,"windspeed_10m":18.5},{"datetime":"2024-10-10T13:00:00.000+02:00","dcPower":417.97423461812605,"power":334.37938769450085,"sunTilt":32.31297830760232,"sunAzimuth":3.5651067967559134,"temperature":12.3,"relativehumidity_2m":91,"windspeed_10m":17.7},{"datetime":"2024-10-10T14:00:00.000+02:00","dcPower":958.9876822522824,"power":767.190145801826,"sunTilt":30.29076264178465,"sunAzimuth":20.858636821509823,"temperature":13.1,"relativehumidity_2m":87,"windspeed_10m":16},{"datetime":"2024-10-10T15:00:00.000+02:00","dcPower":688.4299066938424,"power":550.743925355074,"sunTilt":25.696518065354862,"sunAzimuth":36.92666158608413,"temperature":13.2,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-10T16:00:00.000+02:00","dcPower":610.4752126179212,"power":488.380170094337,"sunTilt":19.087103602605772,"sunAzimuth":51.382837136526646,"temperature":13,"relativehumidity_2m":81,"windspeed_10m":15.1},{"datetime":"2024-10-10T17:00:00.000+02:00","dcPower":479.2637488675229,"power":383.41099909401834,"sunTilt":11.060531491249431,"sunAzimuth":64.39321702308736,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":13.3},{"datetime":"2024-10-10T18:00:00.000+02:00","dcPower":320.570885488488,"power":256.4567083907904,"sunTilt":2.1392339596675116,"sunAzimuth":76.4139573135511,"temperature":12.3,"relativehumidity_2m":79,"windspeed_10m":12.2},{"datetime":"2024-10-10T19:00:00.000+02:00","dcPower":59.25589833135359,"power":47.404718665082875,"sunTilt":-7.240134220715796,"sunAzimuth":88.00936653945188,"temperature":11,"relativehumidity_2m":80,"windspeed_10m":7},{"datetime":"2024-10-10T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.684429132286564,"sunAzimuth":99.79837109714164,"temperature":10.6,"relativehumidity_2m":84,"windspeed_10m":5.2},{"datetime":"2024-10-10T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.780350237977437,"sunAzimuth":112.47374911967749,"temperature":10.1,"relativehumidity_2m":85,"windspeed_10m":4.3},{"datetime":"2024-10-10T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.01656707008997,"sunAzimuth":126.82157256673277,"temperature":10.2,"relativehumidity_2m":84,"windspeed_10m":9.2},{"datetime":"2024-10-10T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.704586491662205,"sunAzimuth":143.61159893587703,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-11T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.97491243886403,"sunAzimuth":163.128648637255,"temperature":9.3,"relativehumidity_2m":92,"windspeed_10m":10.8},{"datetime":"2024-10-11T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.02467456169168,"sunAzimuth":-175.6141848122969,"temperature":8.9,"relativehumidity_2m":94,"windspeed_10m":8.7},{"datetime":"2024-10-11T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.61635732682025,"sunAzimuth":-154.8336646265192,"temperature":8.3,"relativehumidity_2m":91,"windspeed_10m":2.9},{"datetime":"2024-10-11T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.27297819203817,"sunAzimuth":-136.37626404685486,"temperature":8.3,"relativehumidity_2m":93,"windspeed_10m":3.6},{"datetime":"2024-10-11T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.877497329298222,"sunAzimuth":-120.63744534201388,"temperature":8.3,"relativehumidity_2m":95,"windspeed_10m":8.7},{"datetime":"2024-10-11T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.238894548955713,"sunAzimuth":-107.05006215076084,"temperature":8.3,"relativehumidity_2m":98,"windspeed_10m":6.5},{"datetime":"2024-10-11T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.96790216264699,"sunAzimuth":-94.80732853145587,"temperature":8.3,"relativehumidity_2m":96,"windspeed_10m":8.6},{"datetime":"2024-10-11T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.5276603134629134,"sunAzimuth":-83.1591411410959,"temperature":7.9,"relativehumidity_2m":95,"windspeed_10m":7.8},{"datetime":"2024-10-11T08:00:00.000+02:00","dcPower":1.0176040542367717,"power":0.8140832433894174,"sunTilt":5.683710585945252,"sunAzimuth":-71.44774052199065,"temperature":7.5,"relativehumidity_2m":96,"windspeed_10m":6.9},{"datetime":"2024-10-11T09:00:00.000+02:00","dcPower":126.72201909015881,"power":101.37761527212706,"sunTilt":14.260824082236146,"sunAzimuth":-59.0789120948653,"temperature":7.8,"relativehumidity_2m":93,"windspeed_10m":8.6},{"datetime":"2024-10-11T10:00:00.000+02:00","dcPower":443.54021030660033,"power":354.8321682452803,"sunTilt":21.734940400779433,"sunAzimuth":-45.525661029390804,"temperature":8.1,"relativehumidity_2m":89,"windspeed_10m":8.2},{"datetime":"2024-10-11T11:00:00.000+02:00","dcPower":701.7623472438349,"power":561.4098777950679,"sunTilt":27.54875518315372,"sunAzimuth":-30.429767533564867,"temperature":9,"relativehumidity_2m":84,"windspeed_10m":7.6},{"datetime":"2024-10-11T12:00:00.000+02:00","dcPower":1689.8795805363357,"power":1351.9036644290686,"sunTilt":31.105090976135774,"sunAzimuth":-13.830431372703888,"temperature":9.8,"relativehumidity_2m":79,"windspeed_10m":7.4},{"datetime":"2024-10-11T13:00:00.000+02:00","dcPower":1738.8014116916338,"power":1391.041129353307,"sunTilt":31.9332985100557,"sunAzimuth":3.6233914143032355,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":6.5},{"datetime":"2024-10-11T14:00:00.000+02:00","dcPower":877.0554642263434,"power":701.6443713810747,"sunTilt":29.908639212205873,"sunAzimuth":20.83241114244682,"temperature":10.8,"relativehumidity_2m":71,"windspeed_10m":5.1},{"datetime":"2024-10-11T15:00:00.000+02:00","dcPower":766.0323265356332,"power":612.8258612285066,"sunTilt":25.3232617284627,"sunAzimuth":36.83295334168066,"temperature":11.1,"relativehumidity_2m":69,"windspeed_10m":4.3},{"datetime":"2024-10-11T16:00:00.000+02:00","dcPower":622.3257337907264,"power":497.8605870325812,"sunTilt":18.727781114603793,"sunAzimuth":51.243806920166435,"temperature":11.2,"relativehumidity_2m":68,"windspeed_10m":4},{"datetime":"2024-10-11T17:00:00.000+02:00","dcPower":537.8943743441093,"power":430.3154994752875,"sunTilt":10.714725694488843,"sunAzimuth":64.22660182758023,"temperature":10.9,"relativehumidity_2m":71,"windspeed_10m":4.1},{"datetime":"2024-10-11T18:00:00.000+02:00","dcPower":286.67387684312297,"power":229.3391014744984,"sunTilt":1.803206213409536,"sunAzimuth":76.23115674859116,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":0.5},{"datetime":"2024-10-11T19:00:00.000+02:00","dcPower":59.684800500134884,"power":47.74784040010791,"sunTilt":-7.571835214892863,"sunAzimuth":87.81749031617603,"temperature":9,"relativehumidity_2m":79,"windspeed_10m":1.9},{"datetime":"2024-10-11T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.0181265759899,"sunAzimuth":99.60341073267222,"temperature":7.9,"relativehumidity_2m":83,"windspeed_10m":2.4},{"datetime":"2024-10-11T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.122656701936815,"sunAzimuth":112.28457164140065,"temperature":7,"relativehumidity_2m":85,"windspeed_10m":2.9},{"datetime":"2024-10-11T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.37321335134456,"sunAzimuth":126.65558045028693,"temperature":6.5,"relativehumidity_2m":86,"windspeed_10m":3},{"datetime":"2024-10-11T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.07743516418665,"sunAzimuth":143.5011643499793,"temperature":6,"relativehumidity_2m":88,"windspeed_10m":3.3},{"datetime":"2024-10-12T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.35684572865986,"sunAzimuth":163.11851362268473,"temperature":5.4,"relativehumidity_2m":89,"windspeed_10m":4.8},{"datetime":"2024-10-12T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.397705346785955,"sunAzimuth":-175.49580600360676,"temperature":5.1,"relativehumidity_2m":90,"windspeed_10m":4.7},{"datetime":"2024-10-12T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.961535734103165,"sunAzimuth":-154.60751324634,"temperature":4.9,"relativehumidity_2m":90,"windspeed_10m":5.1},{"datetime":"2024-10-12T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.58320673599567,"sunAzimuth":-136.09226665392447,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.1},{"datetime":"2024-10-12T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.157657336040163,"sunAzimuth":-120.33499159290702,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.499060815350038,"sunAzimuth":-106.7481901878237,"temperature":4.8,"relativehumidity_2m":91,"windspeed_10m":5},{"datetime":"2024-10-12T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.218886384957228,"sunAzimuth":-94.51232726794028,"temperature":4.6,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.77960910849284,"sunAzimuth":-82.87218040699524,"temperature":4.5,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T08:00:00.000+02:00","dcPower":1.0272538057118477,"power":0.8218030445694782,"sunTilt":5.421444806545091,"sunAzimuth":-71.16958809655219,"temperature":4.7,"relativehumidity_2m":92,"windspeed_10m":6.6},{"datetime":"2024-10-12T09:00:00.000+02:00","dcPower":189.50437554214903,"power":151.60350043371923,"sunTilt":13.979744388367903,"sunAzimuth":-58.8132832097472,"temperature":5.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-12T10:00:00.000+02:00","dcPower":528.2880619375215,"power":422.63044955001726,"sunTilt":21.42814200059926,"sunAzimuth":-45.28238230412684,"temperature":7.3,"relativehumidity_2m":87,"windspeed_10m":9.9},{"datetime":"2024-10-12T11:00:00.000+02:00","dcPower":764.8989677490279,"power":611.9191741992223,"sunTilt":27.21286115693797,"sunAzimuth":-30.227011858140273,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":10.4},{"datetime":"2024-10-12T12:00:00.000+02:00","dcPower":1665.2058569778644,"power":1332.1646855822917,"sunTilt":30.743014004100917,"sunAzimuth":-13.692125978099353,"temperature":10.1,"relativehumidity_2m":82,"windspeed_10m":12.3},{"datetime":"2024-10-12T13:00:00.000+02:00","dcPower":1547.0060348694196,"power":1237.604827895536,"sunTilt":31.555283363449327,"sunAzimuth":3.6787683789403496,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.4},{"datetime":"2024-10-12T14:00:00.000+02:00","dcPower":1185.607045814405,"power":948.485636651524,"sunTilt":29.52856227644022,"sunAzimuth":20.80440880713897,"temperature":11.2,"relativehumidity_2m":80,"windspeed_10m":11.3},{"datetime":"2024-10-12T15:00:00.000+02:00","dcPower":1052.3012345518403,"power":841.8409876414722,"sunTilt":24.95225483898691,"sunAzimuth":36.7383136221432,"temperature":11.7,"relativehumidity_2m":80,"windspeed_10m":9.7},{"datetime":"2024-10-12T16:00:00.000+02:00","dcPower":711.5593497868869,"power":569.2474798295095,"sunTilt":18.37082552430316,"sunAzimuth":51.10424752804786,"temperature":11.5,"relativehumidity_2m":82,"windspeed_10m":8.4},{"datetime":"2024-10-12T17:00:00.000+02:00","dcPower":468.24413493641043,"power":374.59530794912837,"sunTilt":10.371392509810583,"sunAzimuth":64.05951991866706,"temperature":11.1,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-12T18:00:00.000+02:00","dcPower":169.56037805508723,"power":135.6483024440698,"sunTilt":1.4697694157713943,"sunAzimuth":76.0477365277625,"temperature":10.5,"relativehumidity_2m":87,"windspeed_10m":8.4},{"datetime":"2024-10-12T19:00:00.000+02:00","dcPower":26.236334691702652,"power":20.98906775336212,"sunTilt":-7.900822080898683,"sunAzimuth":87.62468730812897,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":8.5},{"datetime":"2024-10-12T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.349008833561726,"sunAzimuth":99.4070709783674,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":8.6},{"datetime":"2024-10-12T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.462114969564762,"sunAzimuth":112.09341375841275,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":9.2},{"datetime":"2024-10-12T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.7271125456906,"sunAzimuth":126.48693033734844,"temperature":9.7,"relativehumidity_2m":92,"windspeed_10m":8},{"datetime":"2024-10-12T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.447828525061254,"sunAzimuth":143.38762883737587,"temperature":9.4,"relativehumidity_2m":94,"windspeed_10m":7.3},{"datetime":"2024-10-13T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.73675190204195,"sunAzimuth":163.1056210331007,"temperature":9.3,"relativehumidity_2m":94,"windspeed_10m":6.5},{"datetime":"2024-10-13T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.76906905918878,"sunAzimuth":-175.37909348313417,"temperature":9.1,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-13T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.30526573902631,"sunAzimuth":-154.3822860597556,"temperature":9.5,"relativehumidity_2m":92,"windspeed_10m":10},{"datetime":"2024-10-13T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.89221927072221,"sunAzimuth":-135.80926949668307,"temperature":9.6,"relativehumidity_2m":90,"windspeed_10m":16.3},{"datetime":"2024-10-13T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.436918053719957,"sunAzimuth":-120.03396199414307,"temperature":8.8,"relativehumidity_2m":87,"windspeed_10m":18},{"datetime":"2024-10-13T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.75867399010402,"sunAzimuth":-106.44816955814555,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":15.5},{"datetime":"2024-10-13T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.469627601285604,"sunAzimuth":-94.21955537307969,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":17},{"datetime":"2024-10-13T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.0315450153141486,"sunAzimuth":-82.58782406375519,"temperature":7.2,"relativehumidity_2m":82,"windspeed_10m":16.2},{"datetime":"2024-10-13T08:00:00.000+02:00","dcPower":1.0203611260867933,"power":0.8162889008694347,"sunTilt":5.159076534957485,"sunAzimuth":-70.89445910846419,"temperature":6.7,"relativehumidity_2m":84,"windspeed_10m":16.8},{"datetime":"2024-10-13T09:00:00.000+02:00","dcPower":139.76841563365483,"power":111.81473250692386,"sunTilt":13.698600583367673,"sunAzimuth":-58.55115047580995,"temperature":6.9,"relativehumidity_2m":86,"windspeed_10m":15.5},{"datetime":"2024-10-13T10:00:00.000+02:00","dcPower":403.3794838309505,"power":322.7035870647604,"sunTilt":21.12151408595382,"sunAzimuth":-45.043053146408546,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":22.7},{"datetime":"2024-10-13T11:00:00.000+02:00","dcPower":502.01897470778454,"power":401.61517976622764,"sunTilt":26.877585132999467,"sunAzimuth":-30.028428502780528,"temperature":8.1,"relativehumidity_2m":83,"windspeed_10m":19.5},{"datetime":"2024-10-13T12:00:00.000+02:00","dcPower":848.8155858520288,"power":679.052468681623,"sunTilt":30.382147107830978,"sunAzimuth":-13.557685005032592,"temperature":8.5,"relativehumidity_2m":82,"windspeed_10m":18.9},{"datetime":"2024-10-13T13:00:00.000+02:00","dcPower":1354.2793646356383,"power":1083.4234917085107,"sunTilt":31.17904416002473,"sunAzimuth":3.731196531846679,"temperature":9.4,"relativehumidity_2m":79,"windspeed_10m":19.1},{"datetime":"2024-10-13T14:00:00.000+02:00","dcPower":941.4263314178322,"power":753.1410651342658,"sunTilt":29.150649828823855,"sunAzimuth":20.774587486422366,"temperature":10.4,"relativehumidity_2m":75,"windspeed_10m":17.7},{"datetime":"2024-10-13T15:00:00.000+02:00","dcPower":859.830381916628,"power":687.8643055333024,"sunTilt":24.583621654336596,"sunAzimuth":36.642703763191776,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":17.6},{"datetime":"2024-10-13T16:00:00.000+02:00","dcPower":718.8465790980796,"power":575.0772632784636,"sunTilt":18.016365562424813,"sunAzimuth":50.96413031013026,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-13T17:00:00.000+02:00","dcPower":533.6083803641718,"power":426.8867042913375,"sunTilt":10.030663139779685,"sunAzimuth":63.89195411454739,"temperature":9.9,"relativehumidity_2m":79,"windspeed_10m":13.7},{"datetime":"2024-10-13T18:00:00.000+02:00","dcPower":221.67228853371176,"power":177.33783082696942,"sunTilt":1.1390561362362333,"sunAzimuth":75.8636886240363,"temperature":9.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-13T19:00:00.000+02:00","dcPower":30.42969291242704,"power":24.343754329941632,"sunTilt":-8.226960953264081,"sunAzimuth":87.43095436456028,"temperature":8.3,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-13T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.676940156857277,"sunAzimuth":99.2093472034457,"temperature":8.1,"relativehumidity_2m":87,"windspeed_10m":6.4},{"datetime":"2024-10-13T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.79858696849319,"sunAzimuth":111.90025906076745,"temperature":7.6,"relativehumidity_2m":90,"windspeed_10m":6.8},{"datetime":"2024-10-13T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.0781256664598,"sunAzimuth":126.3155777234557,"temperature":7.1,"relativehumidity_2m":90,"windspeed_10m":5},{"datetime":"2024-10-13T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.815632684929426,"sunAzimuth":143.2709019404984,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":2.6},{"datetime":"2024-10-14T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.11451301786215,"sunAzimuth":163.08983536166957,"temperature":6.3,"relativehumidity_2m":89,"windspeed_10m":2.4},{"datetime":"2024-10-14T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.138671848584934,"sunAzimuth":-175.2642016873541,"temperature":5.8,"relativehumidity_2m":89,"windspeed_10m":1.5},{"datetime":"2024-10-14T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.647478099904184,"sunAzimuth":-154.15814092455744,"temperature":5.3,"relativehumidity_2m":90,"windspeed_10m":4},{"datetime":"2024-10-14T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.1999672120048,"sunAzimuth":-135.52742842855076,"temperature":4.5,"relativehumidity_2m":93,"windspeed_10m":1.5},{"datetime":"2024-10-14T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.715244466780028,"sunAzimuth":-119.73450334119632,"temperature":4,"relativehumidity_2m":94,"windspeed_10m":4.5},{"datetime":"2024-10-14T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.01770469074795,"sunAzimuth":-106.15013533780943,"temperature":3.8,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.720095220219516,"sunAzimuth":-93.92913753819082,"temperature":4.2,"relativehumidity_2m":94,"windspeed_10m":5.8},{"datetime":"2024-10-14T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.283430715645694,"sunAzimuth":-82.30618839226909,"temperature":4.7,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.896654311121907,"sunAzimuth":-70.62246187023919,"temperature":5.1,"relativehumidity_2m":93,"windspeed_10m":4.3},{"datetime":"2024-10-14T09:00:00.000+02:00","dcPower":127.65465284846182,"power":102.12372227876946,"sunTilt":13.417455844515738,"sunAzimuth":-58.29261188464403,"temperature":5.6,"relativehumidity_2m":92,"windspeed_10m":3.8},{"datetime":"2024-10-14T10:00:00.000+02:00","dcPower":389.36843020647643,"power":311.49474416518115,"sunTilt":20.815136015137117,"sunAzimuth":-44.807755986987566,"temperature":6.7,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-14T11:00:00.000+02:00","dcPower":592.2220734252635,"power":473.7776587402108,"sunTilt":26.543021174019792,"sunAzimuth":-29.83407952105961,"temperature":7.5,"relativehumidity_2m":85,"windspeed_10m":10.1},{"datetime":"2024-10-14T12:00:00.000+02:00","dcPower":522.2723902421569,"power":417.81791219372553,"sunTilt":30.022594925866507,"sunAzimuth":-13.427153142476177,"temperature":7.8,"relativehumidity_2m":87,"windspeed_10m":7.4},{"datetime":"2024-10-14T13:00:00.000+02:00","dcPower":727.3222067032216,"power":581.8577653625773,"sunTilt":30.804692641790414,"sunAzimuth":3.780636799023051,"temperature":8.7,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-14T14:00:00.000+02:00","dcPower":731.9961730654917,"power":585.5969384523934,"sunTilt":28.775019978770835,"sunAzimuth":20.742907009369926,"temperature":8.8,"relativehumidity_2m":83,"windspeed_10m":7.7},{"datetime":"2024-10-14T15:00:00.000+02:00","dcPower":673.1869547969405,"power":538.5495638375525,"sunTilt":24.217486258022436,"sunAzimuth":36.546087194880954,"temperature":8.8,"relativehumidity_2m":85,"windspeed_10m":6.5},{"datetime":"2024-10-14T16:00:00.000+02:00","dcPower":565.1327402072765,"power":452.1061921658212,"sunTilt":17.664529576020236,"sunAzimuth":50.82342857831685,"temperature":8.7,"relativehumidity_2m":88,"windspeed_10m":5.2},{"datetime":"2024-10-14T17:00:00.000+02:00","dcPower":392.09431169870487,"power":313.67544935896393,"sunTilt":9.692668263612882,"sunAzimuth":63.7238891712367,"temperature":8.4,"relativehumidity_2m":90,"windspeed_10m":3.6},{"datetime":"2024-10-14T18:00:00.000+02:00","dcPower":181.97709304355863,"power":145.58167443484692,"sunTilt":0.8111983279457368,"sunAzimuth":75.67900710147994,"temperature":8.2,"relativehumidity_2m":91,"windspeed_10m":1.8},{"datetime":"2024-10-14T19:00:00.000+02:00","dcPower":30.46631439555601,"power":24.37305151644481,"sunTilt":-8.5501186243532,"sunAzimuth":87.23629074336492,"temperature":7.9,"relativehumidity_2m":93,"windspeed_10m":0.4},{"datetime":"2024-10-14T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.00178541748869,"sunAzimuth":99.01023762777291,"temperature":7.4,"relativehumidity_2m":94,"windspeed_10m":1.1},{"datetime":"2024-10-14T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.131935065748255,"sunAzimuth":111.70509440468732,"temperature":6.7,"relativehumidity_2m":96,"windspeed_10m":1.1},{"datetime":"2024-10-14T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.426113802819536,"sunAzimuth":126.14148133580082,"temperature":5.8,"relativehumidity_2m":98,"windspeed_10m":0.4},{"datetime":"2024-10-14T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.180713378529184,"sunAzimuth":143.15089515757862,"temperature":5,"relativehumidity_2m":100,"windspeed_10m":0.7},{"datetime":"2024-10-15T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.49001057641694,"sunAzimuth":163.07102046565072,"temperature":4.3,"relativehumidity_2m":100,"windspeed_10m":1.4},{"datetime":"2024-10-15T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.506419588733614,"sunAzimuth":-175.15128768425117,"temperature":3.8,"relativehumidity_2m":100,"windspeed_10m":1.9},{"datetime":"2024-10-15T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.988103658491234,"sunAzimuth":-153.9352385869432,"temperature":3.2,"relativehumidity_2m":100,"windspeed_10m":2.6},{"datetime":"2024-10-15T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.50640211284843,"sunAzimuth":-135.24690137263536,"temperature":2.4,"relativehumidity_2m":100,"windspeed_10m":2.9},{"datetime":"2024-10-15T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.992601433814595,"sunAzimuth":-119.43676351875516,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.2},{"datetime":"2024-10-15T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.276123004228086,"sunAzimuth":-105.85422295783262,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.970257710966642,"sunAzimuth":-93.64119828341282,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.5352276078301,"sunAzimuth":-82.02738904900566,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.63422819396966,"sunAzimuth":-70.35370359566531,"temperature":2.3,"relativehumidity_2m":100,"windspeed_10m":4},{"datetime":"2024-10-15T09:00:00.000+02:00","dcPower":154.33046643149038,"power":123.46437314519231,"sunTilt":13.136374944499462,"sunAzimuth":-58.037763824819606,"temperature":3.4,"relativehumidity_2m":98,"windspeed_10m":5},{"datetime":"2024-10-15T10:00:00.000+02:00","dcPower":437.4885594761827,"power":349.99084758094614,"sunTilt":20.50908861823578,"sunAzimuth":-44.57657122307415,"temperature":4.8,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-15T11:00:00.000+02:00","dcPower":659.6002948049094,"power":527.6802358439276,"sunTilt":26.20926450679345,"sunAzimuth":-29.644024765315514,"temperature":6.3,"relativehumidity_2m":91,"windspeed_10m":7.8},{"datetime":"2024-10-15T12:00:00.000+02:00","dcPower":778.8402349217745,"power":623.0721879374196,"sunTilt":29.66446288060627,"sunAzimuth":-13.300572957692536,"temperature":8,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-15T13:00:00.000+02:00","dcPower":1978.9817835346914,"power":1583.1854268277532,"sunTilt":30.43234097439692,"sunAzimuth":3.8270522061418095,"temperature":9.7,"relativehumidity_2m":84,"windspeed_10m":10.9},{"datetime":"2024-10-15T14:00:00.000+02:00","dcPower":752.5495986025528,"power":602.0396788820423,"sunTilt":28.401790922595836,"sunAzimuth":20.709329389098386,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":11.9},{"datetime":"2024-10-15T15:00:00.000+02:00","dcPower":688.3741880723578,"power":550.6993504578862,"sunTilt":23.853972526435296,"sunAzimuth":36.448429487078144,"temperature":11.2,"relativehumidity_2m":81,"windspeed_10m":11.9},{"datetime":"2024-10-15T16:00:00.000+02:00","dcPower":586.2062141110889,"power":468.96497128887114,"sunTilt":17.315445485866938,"sunAzimuth":50.682117672382816,"temperature":11,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-15T17:00:00.000+02:00","dcPower":434.3328809254071,"power":347.4663047403257,"sunTilt":9.35753798215806,"sunAzimuth":63.55531186506398,"temperature":10.6,"relativehumidity_2m":82,"windspeed_10m":11.1},{"datetime":"2024-10-15T18:00:00.000+02:00","dcPower":236.41723235517372,"power":189.133785884139,"sunTilt":0.48632726290863104,"sunAzimuth":75.49368820866877,"temperature":9.7,"relativehumidity_2m":85,"windspeed_10m":12.1},{"datetime":"2024-10-15T19:00:00.000+02:00","dcPower":50.711744075262686,"power":40.56939526021015,"sunTilt":-8.870162619560801,"sunAzimuth":87.04069822248259,"temperature":8.5,"relativehumidity_2m":88,"windspeed_10m":13.6},{"datetime":"2024-10-15T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.323410184499085,"sunAzimuth":98.80974345652376,"temperature":7.6,"relativehumidity_2m":91,"windspeed_10m":14.3},{"datetime":"2024-10-15T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.462022150796383,"sunAzimuth":111.50791010170794,"temperature":7.2,"relativehumidity_2m":91,"windspeed_10m":13.9},{"datetime":"2024-10-15T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.770938188604354,"sunAzimuth":125.96460338334036,"temperature":7,"relativehumidity_2m":91,"windspeed_10m":12.9},{"datetime":"2024-10-15T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.54293600102064,"sunAzimuth":143.02752222319543,"temperature":6.8,"relativehumidity_2m":89,"windspeed_10m":12.7},{"datetime":"2024-10-16T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.86312551477473,"sunAzimuth":163.04903976266138,"temperature":6.7,"relativehumidity_2m":86,"windspeed_10m":13.8},{"datetime":"2024-10-16T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.87221785059982,"sunAzimuth":-175.04051110279474,"temperature":6.8,"relativehumidity_2m":83,"windspeed_10m":15.6},{"datetime":"2024-10-16T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.32707330350441,"sunAzimuth":-153.71374265034802,"temperature":6.8,"relativehumidity_2m":80,"windspeed_10m":16.5},{"datetime":"2024-10-16T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.81147561550361,"sunAzimuth":-134.96784827714956,"temperature":7,"relativehumidity_2m":78,"windspeed_10m":16.1},{"datetime":"2024-10-16T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.26895363243685,"sunAzimuth":-119.1408914506387,"temperature":7.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-16T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.53389843309148,"sunAzimuth":-105.56056815727264,"temperature":7.4,"relativehumidity_2m":75,"windspeed_10m":14.3},{"datetime":"2024-10-16T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.220082554609817,"sunAzimuth":-93.355861910257,"temperature":7.4,"relativehumidity_2m":73,"windspeed_10m":14.3},{"datetime":"2024-10-16T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.786895768698711,"sunAzimuth":-81.75154101774274,"temperature":7.3,"relativehumidity_2m":71,"windspeed_10m":14.7},{"datetime":"2024-10-16T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.371849783596975,"sunAzimuth":-70.08829035405888,"temperature":7.2,"relativehumidity_2m":70,"windspeed_10m":14.8},{"datetime":"2024-10-16T09:00:00.000+02:00","dcPower":115.85164932968384,"power":92.68131946374707,"sunTilt":12.855424254104108,"sunAzimuth":-57.78670104663053,"temperature":9.8,"relativehumidity_2m":72,"windspeed_10m":17},{"datetime":"2024-10-16T10:00:00.000+02:00","dcPower":335.28441038828294,"power":268.2275283106264,"sunTilt":20.203454185088123,"sunAzimuth":-44.34957719582011,"temperature":10.2,"relativehumidity_2m":75,"windspeed_10m":16.5},{"datetime":"2024-10-16T11:00:00.000+02:00","dcPower":527.1146116233759,"power":421.6916892987008,"sunTilt":25.87641150088954,"sunAzimuth":-29.45832187587005,"temperature":10.7,"relativehumidity_2m":77,"windspeed_10m":16.2},{"datetime":"2024-10-16T12:00:00.000+02:00","dcPower":710.6991019378397,"power":568.5592815502717,"sunTilt":29.30785715204484,"sunAzimuth":-13.177984892004138,"temperature":11.6,"relativehumidity_2m":77,"windspeed_10m":15.7},{"datetime":"2024-10-16T13:00:00.000+02:00","dcPower":1436.9262866801166,"power":1149.5410293440934,"sunTilt":30.062101719417093,"sunAzimuth":3.870407893111919,"temperature":12.5,"relativehumidity_2m":75,"windspeed_10m":15.3},{"datetime":"2024-10-16T14:00:00.000+02:00","dcPower":903.66353184464,"power":722.930825475712,"sunTilt":28.03108091352568,"sunAzimuth":20.673818848950724,"temperature":13.2,"relativehumidity_2m":75,"windspeed_10m":14.8},{"datetime":"2024-10-16T15:00:00.000+02:00","dcPower":821.4637948690453,"power":657.1710358952363,"sunTilt":23.49320409080551,"sunAzimuth":36.34969840216598,"temperature":13.5,"relativehumidity_2m":77,"windspeed_10m":14.3},{"datetime":"2024-10-16T16:00:00.000+02:00","dcPower":643.408791431554,"power":514.7270331452432,"sunTilt":16.969240740198778,"sunAzimuth":50.54017502787197,"temperature":13.5,"relativehumidity_2m":80,"windspeed_10m":13.7},{"datetime":"2024-10-16T17:00:00.000+02:00","dcPower":439.6091142498436,"power":351.68729139987494,"sunTilt":9.025401762418719,"sunAzimuth":63.386211071389155,"temperature":13.3,"relativehumidity_2m":82,"windspeed_10m":13.2},{"datetime":"2024-10-16T18:00:00.000+02:00","dcPower":218.69491379498575,"power":174.9559310359886,"sunTilt":0.16457346744538273,"sunAzimuth":75.30773046735078,"temperature":13,"relativehumidity_2m":82,"windspeed_10m":13},{"datetime":"2024-10-16T19:00:00.000+02:00","dcPower":44.96868965159397,"power":35.974951721275175,"sunTilt":-9.186961271997145,"sunAzimuth":86.84418120594421,"temperature":12.6,"relativehumidity_2m":81,"windspeed_10m":12.7},{"datetime":"2024-10-16T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.64168080965683,"sunAzimuth":98.60786901950698,"temperature":12.2,"relativehumidity_2m":80,"windspeed_10m":12.2},{"datetime":"2024-10-16T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.788711717705176,"sunAzimuth":111.30870010109864,"temperature":12,"relativehumidity_2m":80,"windspeed_10m":11.8},{"datetime":"2024-10-16T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.112460277357016,"sunAzimuth":125.78490981561183,"temperature":11.8,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-16T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.9021656490531,"sunAzimuth":142.90069940402603,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":10.2},{"datetime":"2024-10-17T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.23373820466217,"sunAzimuth":163.02375644913673,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":9.4},{"datetime":"2024-10-17T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-48.2359718752176,"sunAzimuth":-174.9320340577077,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.6},{"datetime":"2024-10-17T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.66431793404074,"sunAzimuth":-153.4938195445769,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.2},{"datetime":"2024-10-17T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.115139405653565,"sunAzimuth":-134.69043107615582,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.9},{"datetime":"2024-10-17T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.54426550294801,"sunAzimuth":-118.84703704658551,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.79099983796868,"sunAzimuth":-105.2693069294498,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.469536195186127,"sunAzimuth":-93.07325245162968,"temperature":11.6,"relativehumidity_2m":82,"windspeed_10m":7.4},{"datetime":"2024-10-17T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-5.038393916921545,"sunAzimuth":-81.47875856041325,"temperature":11.4,"relativehumidity_2m":83,"windspeed_10m":7.3},{"datetime":"2024-10-17T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.109572239802723,"sunAzimuth":-69.8263270263417,"temperature":11.5,"relativehumidity_2m":83,"windspeed_10m":7.4},{"datetime":"2024-10-17T09:00:00.000+02:00","dcPower":105.03214056441247,"power":84.02571245152998,"sunTilt":12.574671747325786,"sunAzimuth":-57.53951661964237,"temperature":11.9,"relativehumidity_2m":83,"windspeed_10m":8},{"datetime":"2024-10-17T10:00:00.000+02:00","dcPower":312.8842945992691,"power":250.30743567941528,"sunTilt":19.89831645004539,"sunAzimuth":-44.1268501705138,"temperature":12.5,"relativehumidity_2m":82,"windspeed_10m":9.7},{"datetime":"2024-10-17T11:00:00.000+02:00","dcPower":488.55377841324116,"power":390.84302273059296,"sunTilt":25.54455964459646,"sunAzimuth":-29.277026273264024,"temperature":13.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-17T12:00:00.000+02:00","dcPower":633.4064482929499,"power":506.72515863435996,"sunTilt":28.952884649607554,"sunAzimuth":-13.059427258390235,"temperature":13.5,"relativehumidity_2m":83,"windspeed_10m":10.7},{"datetime":"2024-10-17T13:00:00.000+02:00","dcPower":729.049282498162,"power":583.2394259985297,"sunTilt":29.694087805098004,"sunAzimuth":3.9106711264578404,"temperature":13.8,"relativehumidity_2m":85,"windspeed_10m":10.8},{"datetime":"2024-10-17T14:00:00.000+02:00","dcPower":761.5535689723715,"power":609.2428551778972,"sunTilt":27.663008228484166,"sunAzimuth":20.63634185579284,"temperature":14.2,"relativehumidity_2m":86,"windspeed_10m":10.5},{"datetime":"2024-10-17T15:00:00.000+02:00","dcPower":726.9635320188225,"power":581.5708256150581,"sunTilt":23.13530429931852,"sunAzimuth":36.24986394147597,"temperature":14.6,"relativehumidity_2m":87,"windspeed_10m":9.8},{"datetime":"2024-10-17T16:00:00.000+02:00","dcPower":625.4301522062517,"power":500.3441217650014,"sunTilt":16.626042266391202,"sunAzimuth":50.39758024284303,"temperature":14.9,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-17T17:00:00.000+02:00","dcPower":451.1586680783196,"power":360.9269344626557,"sunTilt":8.696388379725821,"sunAzimuth":63.21657784217465,"temperature":15.1,"relativehumidity_2m":89,"windspeed_10m":8.7},{"datetime":"2024-10-17T18:00:00.000+02:00","dcPower":222.0746839182061,"power":177.6597471345649,"sunTilt":-0.15393334821361332,"sunAzimuth":75.12113476396313,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-17T19:00:00.000+02:00","dcPower":39.6933615946055,"power":31.7546892756844,"sunTilt":-9.500383797971471,"sunAzimuth":86.64674682587622,"temperature":14.6,"relativehumidity_2m":92,"windspeed_10m":6.7},{"datetime":"2024-10-17T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.956464510241965,"sunAzimuth":98.40462190157557,"temperature":14.3,"relativehumidity_2m":93,"windspeed_10m":5.9},{"datetime":"2024-10-17T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-28.11186795253563,"sunAzimuth":111.10746217430679,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":5.4},{"datetime":"2024-10-17T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.45054181856139,"sunAzimuth":125.60237057929713,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":4.6},{"datetime":"2024-10-17T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.2582671659911,"sunAzimuth":142.77034580724174,"temperature":14.2,"relativehumidity_2m":95,"windspeed_10m":4.4}],[{"datetime":"2024-10-06T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.03892891605494,"sunAzimuth":163.14263622624128,"temperature":7,"relativehumidity_2m":88,"windspeed_10m":7.9},{"datetime":"2024-10-06T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.13778324543035,"sunAzimuth":-176.22585898864278,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":6.8},{"datetime":"2024-10-06T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.87117274960936,"sunAzimuth":-155.9729639229445,"temperature":6,"relativehumidity_2m":91,"windspeed_10m":5.9},{"datetime":"2024-10-06T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.70531092777589,"sunAzimuth":-137.8059489226708,"temperature":5.5,"relativehumidity_2m":92,"windspeed_10m":5.1},{"datetime":"2024-10-06T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.46443142893699,"sunAzimuth":-122.16602054266892,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.2},{"datetime":"2024-10-06T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-20.930765133481234,"sunAzimuth":-108.58249513077881,"temperature":4.9,"relativehumidity_2m":93,"windspeed_10m":5.8},{"datetime":"2024-10-06T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.710347315474053,"sunAzimuth":-96.31140508589108,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.5},{"datetime":"2024-10-06T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.2689443207496223,"sunAzimuth":-84.62890292197706,"temperature":5.3,"relativehumidity_2m":92,"windspeed_10m":7.6},{"datetime":"2024-10-06T08:00:00.000+02:00","dcPower":2.705705892666875,"power":2.1645647141335003,"sunTilt":6.991906328571172,"sunAzimuth":-72.87999206290318,"temperature":5.5,"relativehumidity_2m":91,"windspeed_10m":8.4},{"datetime":"2024-10-06T09:00:00.000+02:00","dcPower":57.65254422751687,"power":46.1220353820135,"sunTilt":15.663160391528187,"sunAzimuth":-60.45596163553978,"temperature":6.3,"relativehumidity_2m":90,"windspeed_10m":9.3},{"datetime":"2024-10-06T10:00:00.000+02:00","dcPower":119.20917355200001,"power":95.36733884160002,"sunTilt":23.268816289666535,"sunAzimuth":-46.79827360798693,"temperature":8,"relativehumidity_2m":85,"windspeed_10m":11.6},{"datetime":"2024-10-06T11:00:00.000+02:00","dcPower":176.16728214926752,"power":140.93382571941402,"sunTilt":29.234287239795027,"sunAzimuth":-31.503805204051176,"temperature":9.5,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-06T12:00:00.000+02:00","dcPower":564.3026414176044,"power":451.4421131340835,"sunTilt":32.93002248275174,"sunAzimuth":-14.578212396799534,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":16.3},{"datetime":"2024-10-06T13:00:00.000+02:00","dcPower":540.9369663099236,"power":432.7495730479389,"sunTilt":33.84613522696556,"sunAzimuth":3.3037874055175505,"temperature":10.9,"relativehumidity_2m":82,"windspeed_10m":16.9},{"datetime":"2024-10-06T14:00:00.000+02:00","dcPower":464.5359852589149,"power":371.62878820713195,"sunTilt":31.83736693728352,"sunAzimuth":20.94669333759787,"temperature":12.6,"relativehumidity_2m":74,"windspeed_10m":17.1},{"datetime":"2024-10-06T15:00:00.000+02:00","dcPower":477.45533013060344,"power":381.96426410448277,"sunTilt":27.209548486852757,"sunAzimuth":37.29302345489315,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":17.8},{"datetime":"2024-10-06T16:00:00.000+02:00","dcPower":307.88619712830206,"power":246.30895770264166,"sunTilt":20.54547686442047,"sunAzimuth":51.93430385037965,"temperature":13.1,"relativehumidity_2m":73,"windspeed_10m":17.8},{"datetime":"2024-10-06T17:00:00.000+02:00","dcPower":187.53043014586999,"power":150.024344116696,"sunTilt":12.4658413410018,"sunAzimuth":65.05541740712634,"temperature":12.6,"relativehumidity_2m":72,"windspeed_10m":15.3},{"datetime":"2024-10-06T18:00:00.000+02:00","dcPower":99.74907463554689,"power":79.79925970843752,"sunTilt":3.5065849097251456,"sunAzimuth":77.13919140741508,"temperature":11.9,"relativehumidity_2m":76,"windspeed_10m":14.8},{"datetime":"2024-10-06T19:00:00.000+02:00","dcPower":19.860135578171874,"power":15.8881084625375,"sunTilt":-5.8888854618235795,"sunAzimuth":88.76774476136781,"temperature":11.1,"relativehumidity_2m":79,"windspeed_10m":15.1},{"datetime":"2024-10-06T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.324219942418523,"sunAzimuth":100.56460329657087,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.2},{"datetime":"2024-10-06T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.385415066790006,"sunAzimuth":113.21108724923529,"temperature":10.3,"relativehumidity_2m":81,"windspeed_10m":13.4},{"datetime":"2024-10-06T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.56528997863786,"sunAzimuth":127.45995077522508,"temperature":10.3,"relativehumidity_2m":83,"windspeed_10m":13.4},{"datetime":"2024-10-06T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.19130152908581,"sunAzimuth":144.02419079232183,"temperature":10.7,"relativehumidity_2m":83,"windspeed_10m":13},{"datetime":"2024-10-07T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.4292477470776,"sunAzimuth":163.14429087891105,"temperature":11,"relativehumidity_2m":83,"windspeed_10m":13.6},{"datetime":"2024-10-07T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.517749495901406,"sunAzimuth":-176.10136944486192,"temperature":11,"relativehumidity_2m":87,"windspeed_10m":11.2},{"datetime":"2024-10-07T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.22254930467857,"sunAzimuth":-155.74445709329385,"temperature":10.9,"relativehumidity_2m":91,"windspeed_10m":9.8},{"datetime":"2024-10-07T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.020882886444426,"sunAzimuth":-137.5192133151141,"temperature":11,"relativehumidity_2m":93,"windspeed_10m":8.7},{"datetime":"2024-10-07T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.748563322135755,"sunAzimuth":-121.8586066543482,"temperature":11.4,"relativehumidity_2m":94,"windspeed_10m":8.9},{"datetime":"2024-10-07T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.193270783038106,"sunAzimuth":-108.27337691467278,"temperature":11.5,"relativehumidity_2m":95,"windspeed_10m":11},{"datetime":"2024-10-07T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.962117012521006,"sunAzimuth":-96.00713008699226,"temperature":12,"relativehumidity_2m":94,"windspeed_10m":8.4},{"datetime":"2024-10-07T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.520439907672165,"sunAzimuth":-84.33068309080377,"temperature":12.2,"relativehumidity_2m":95,"windspeed_10m":9.3},{"datetime":"2024-10-07T08:00:00.000+02:00","dcPower":1.1877547101875001,"power":0.9502037681500002,"sunTilt":6.730829735879827,"sunAzimuth":-72.58838243599898,"temperature":12.7,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-07T09:00:00.000+02:00","dcPower":24.298528725187502,"power":19.438822980150004,"sunTilt":15.383293627086166,"sunAzimuth":-60.174375329765816,"temperature":13.3,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-07T10:00:00.000+02:00","dcPower":99.00096165043,"power":79.20076932034401,"sunTilt":22.96230371458641,"sunAzimuth":-46.53655309716923,"temperature":14.4,"relativehumidity_2m":91,"windspeed_10m":8.9},{"datetime":"2024-10-07T11:00:00.000+02:00","dcPower":90.58295243867188,"power":72.46636195093751,"sunTilt":28.896671975172765,"sunAzimuth":-31.281195510232457,"temperature":15.2,"relativehumidity_2m":91,"windspeed_10m":10.9},{"datetime":"2024-10-07T12:00:00.000+02:00","dcPower":206.2311706601901,"power":164.98493652815208,"sunTilt":32.56343562699629,"sunAzimuth":-14.421329305847635,"temperature":16,"relativehumidity_2m":89,"windspeed_10m":8.4},{"datetime":"2024-10-07T13:00:00.000+02:00","dcPower":380.37752208740244,"power":304.30201766992195,"sunTilt":33.46089993111447,"sunAzimuth":3.3732496911031458,"temperature":17,"relativehumidity_2m":82,"windspeed_10m":10.5},{"datetime":"2024-10-07T14:00:00.000+02:00","dcPower":444.5766828263196,"power":355.6613462610557,"sunTilt":31.448234246769605,"sunAzimuth":20.927108911856653,"temperature":18,"relativehumidity_2m":75,"windspeed_10m":9.8},{"datetime":"2024-10-07T15:00:00.000+02:00","dcPower":374.63788289233753,"power":299.71030631387003,"sunTilt":26.828539273003113,"sunAzimuth":37.20261442198752,"temperature":18.6,"relativehumidity_2m":70,"windspeed_10m":5.8},{"datetime":"2024-10-07T16:00:00.000+02:00","dcPower":252.36015882335633,"power":201.8881270586851,"sunTilt":20.17798028996772,"sunAzimuth":51.797067137147856,"temperature":18.9,"relativehumidity_2m":70,"windspeed_10m":7.4},{"datetime":"2024-10-07T17:00:00.000+02:00","dcPower":185.30274165096753,"power":148.24219332077402,"sunTilt":12.11146577521795,"sunAzimuth":64.89046144901918,"temperature":18.4,"relativehumidity_2m":72,"windspeed_10m":7.2},{"datetime":"2024-10-07T18:00:00.000+02:00","dcPower":83.06339266979688,"power":66.45071413583752,"sunTilt":3.1615292467832945,"sunAzimuth":76.95875071433478,"temperature":17.4,"relativehumidity_2m":74,"windspeed_10m":9.5},{"datetime":"2024-10-07T19:00:00.000+02:00","dcPower":18.1767567334675,"power":14.541405386774,"sunTilt":-6.2300935503595385,"sunAzimuth":88.57949984494998,"temperature":16.4,"relativehumidity_2m":79,"windspeed_10m":7.6},{"datetime":"2024-10-07T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.667810875617224,"sunAzimuth":100.37506201699385,"temperature":15.4,"relativehumidity_2m":87,"windspeed_10m":9.2},{"datetime":"2024-10-07T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.737727405113922,"sunAzimuth":113.02960837119129,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":10.9},{"datetime":"2024-10-07T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.93153539182831,"sunAzimuth":127.30408938398604,"temperature":14.5,"relativehumidity_2m":94,"windspeed_10m":10.7},{"datetime":"2024-10-07T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.572641175501424,"sunAzimuth":143.925225436442,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":11.6},{"datetime":"2024-10-08T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.818121034022454,"sunAzimuth":163.14384998115503,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":15.8},{"datetime":"2024-10-08T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.8965146817323,"sunAzimuth":-175.97781528741945,"temperature":14.8,"relativehumidity_2m":93,"windspeed_10m":14},{"datetime":"2024-10-08T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.572826067170936,"sunAzimuth":-155.51612909180187,"temperature":15.4,"relativehumidity_2m":94,"windspeed_10m":11.2},{"datetime":"2024-10-08T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.33548533853669,"sunAzimuth":-137.23273090041528,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":12.2},{"datetime":"2024-10-08T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.031970973409337,"sunAzimuth":-121.55190085899577,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":11.2},{"datetime":"2024-10-08T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.45536424486834,"sunAzimuth":-107.96544161465735,"temperature":15.8,"relativehumidity_2m":94,"windspeed_10m":10},{"datetime":"2024-10-08T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.213784351950519,"sunAzimuth":-95.70446004731738,"temperature":15.8,"relativehumidity_2m":93,"windspeed_10m":10.8},{"datetime":"2024-10-08T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.772091353585381,"sunAzimuth":-84.03447848078322,"temperature":15.7,"relativehumidity_2m":93,"windspeed_10m":10.5},{"datetime":"2024-10-08T08:00:00.000+02:00","dcPower":0.9403939745200003,"power":0.7523151796160003,"sunTilt":6.469429830924286,"sunAzimuth":-72.29923977528891,"temperature":15.7,"relativehumidity_2m":90,"windspeed_10m":10.2},{"datetime":"2024-10-08T09:00:00.000+02:00","dcPower":49.46079645283,"power":39.568637162264004,"sunTilt":15.10307050684314,"sunAzimuth":-59.895772492079,"temperature":15.7,"relativehumidity_2m":89,"windspeed_10m":8.3},{"datetime":"2024-10-08T10:00:00.000+02:00","dcPower":133.24586591603,"power":106.59669273282401,"sunTilt":22.655587204347487,"sunAzimuth":-46.27834028428006,"temperature":16.3,"relativehumidity_2m":86,"windspeed_10m":10.6},{"datetime":"2024-10-08T11:00:00.000+02:00","dcPower":196.52604667500003,"power":157.22083734000003,"sunTilt":28.55922255887939,"sunAzimuth":-31.06241498925654,"temperature":17,"relativehumidity_2m":85,"windspeed_10m":9},{"datetime":"2024-10-08T12:00:00.000+02:00","dcPower":334.8024958939491,"power":267.84199671515927,"sunTilt":32.19754822667087,"sunAzimuth":-14.26805544520534,"temperature":17.6,"relativehumidity_2m":82,"windspeed_10m":13.1},{"datetime":"2024-10-08T13:00:00.000+02:00","dcPower":455.8287658376337,"power":364.663012670107,"sunTilt":33.07688907994966,"sunAzimuth":3.4399896103944245,"temperature":18.3,"relativehumidity_2m":73,"windspeed_10m":12.7},{"datetime":"2024-10-08T14:00:00.000+02:00","dcPower":372.2582413893144,"power":297.8065931114515,"sunTilt":31.060677746554727,"sunAzimuth":20.90593811884074,"temperature":18.2,"relativehumidity_2m":71,"windspeed_10m":13.2},{"datetime":"2024-10-08T15:00:00.000+02:00","dcPower":311.9842847493126,"power":249.5874277994501,"sunTilt":26.44928138282813,"sunAzimuth":37.11144846293623,"temperature":18.1,"relativehumidity_2m":71,"windspeed_10m":13.3},{"datetime":"2024-10-08T16:00:00.000+02:00","dcPower":199.64949036591122,"power":159.71959229272898,"sunTilt":19.81233269039211,"sunAzimuth":51.65943401571543,"temperature":17.4,"relativehumidity_2m":73,"windspeed_10m":9.2},{"datetime":"2024-10-08T17:00:00.000+02:00","dcPower":141.4621015685675,"power":113.16968125485401,"sunTilt":11.759033809196055,"sunAzimuth":64.72512518734406,"temperature":16.9,"relativehumidity_2m":75,"windspeed_10m":6.3},{"datetime":"2024-10-08T18:00:00.000+02:00","dcPower":80.56004293366748,"power":64.44803434693398,"sunTilt":2.818529310708217,"sunAzimuth":76.77774147088243,"temperature":16,"relativehumidity_2m":75,"windspeed_10m":8.6},{"datetime":"2024-10-08T19:00:00.000+02:00","dcPower":18.238636997546877,"power":14.590909598037502,"sunTilt":-6.569128186596377,"sunAzimuth":88.39036262615889,"temperature":15,"relativehumidity_2m":82,"windspeed_10m":5.8},{"datetime":"2024-10-08T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.00913432264249,"sunAzimuth":100.18418560559792,"temperature":14.5,"relativehumidity_2m":89,"windspeed_10m":4.7},{"datetime":"2024-10-08T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.087746753502067,"sunAzimuth":112.84624373263145,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":8.4},{"datetime":"2024-10-08T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.29558919415396,"sunAzimuth":127.1457755347392,"temperature":14,"relativehumidity_2m":88,"windspeed_10m":8.6},{"datetime":"2024-10-08T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.95205668980692,"sunAzimuth":143.82353531827584,"temperature":13,"relativehumidity_2m":91,"windspeed_10m":7.2},{"datetime":"2024-10-09T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.20543351779722,"sunAzimuth":163.1411836959831,"temperature":12.6,"relativehumidity_2m":92,"windspeed_10m":7.8},{"datetime":"2024-10-09T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.27398595213309,"sunAzimuth":-175.8553370630724,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.5},{"datetime":"2024-10-09T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.92193289241474,"sunAzimuth":-155.28812310014723,"temperature":12.7,"relativehumidity_2m":92,"windspeed_10m":10.5},{"datetime":"2024-10-09T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.64906836752005,"sunAzimuth":-136.94664669208646,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":12.3},{"datetime":"2024-10-09T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.314619206373006,"sunAzimuth":-121.24604384005964,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.2},{"datetime":"2024-10-09T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.71701795569338,"sunAzimuth":-107.65882183814558,"temperature":13.2,"relativehumidity_2m":90,"windspeed_10m":12.9},{"datetime":"2024-10-09T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.465322674764078,"sunAzimuth":-95.40351983003244,"temperature":13.3,"relativehumidity_2m":89,"windspeed_10m":13.5},{"datetime":"2024-10-09T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.023867147662328,"sunAzimuth":-83.74040781223535,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":13.2},{"datetime":"2024-10-09T08:00:00.000+02:00","dcPower":0.23740534840750008,"power":0.18992427872600007,"sunTilt":6.207747953736028,"sunAzimuth":-72.01267721735641,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":11.8},{"datetime":"2024-10-09T09:00:00.000+02:00","dcPower":12.811284756270002,"power":10.249027805016002,"sunTilt":14.82254636430179,"sunAzimuth":-59.62025856421435,"temperature":12.8,"relativehumidity_2m":93,"windspeed_10m":11.8},{"datetime":"2024-10-09T10:00:00.000+02:00","dcPower":40.81670722668,"power":32.653365781344,"sunTilt":22.348738640203063,"sunAzimuth":-46.023727432629336,"temperature":13.1,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T11:00:00.000+02:00","dcPower":83.24077643426999,"power":66.592621147416,"sunTilt":28.22202694317921,"sunAzimuth":-30.847536638720733,"temperature":13.7,"relativehumidity_2m":92,"windspeed_10m":12.8},{"datetime":"2024-10-09T12:00:00.000+02:00","dcPower":126.537344683617,"power":101.2298757468936,"sunTilt":31.832460658762763,"sunAzimuth":-14.118446086753368,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":12},{"datetime":"2024-10-09T13:00:00.000+02:00","dcPower":114.88404360475715,"power":91.90723488380573,"sunTilt":32.69421193791607,"sunAzimuth":3.503957762689472,"temperature":14.2,"relativehumidity_2m":90,"windspeed_10m":11.5},{"datetime":"2024-10-09T14:00:00.000+02:00","dcPower":84.81614681380752,"power":67.85291745104603,"sunTilt":30.674814714273392,"sunAzimuth":20.883130296493302,"temperature":13.9,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T15:00:00.000+02:00","dcPower":48.3547931762223,"power":38.68383454097784,"sunTilt":26.07189945210854,"sunAzimuth":37.01947906146245,"temperature":13.8,"relativehumidity_2m":92,"windspeed_10m":13.8},{"datetime":"2024-10-09T16:00:00.000+02:00","dcPower":37.58272513091204,"power":30.066180104729632,"sunTilt":19.448663917806936,"sunAzimuth":51.521368716140636,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":14.5},{"datetime":"2024-10-09T17:00:00.000+02:00","dcPower":24.8262611926875,"power":19.86100895415,"sunTilt":11.408678227135676,"sunAzimuth":64.55938454107346,"temperature":13.5,"relativehumidity_2m":90,"windspeed_10m":15.8},{"datetime":"2024-10-09T18:00:00.000+02:00","dcPower":14.794745341796876,"power":11.835796273437502,"sunTilt":2.4777195309842384,"sunAzimuth":76.59614824640437,"temperature":13.4,"relativehumidity_2m":92,"windspeed_10m":13.4},{"datetime":"2024-10-09T19:00:00.000+02:00","dcPower":3.3148202878699995,"power":2.6518562302959996,"sunTilt":-6.905853552820109,"sunAzimuth":88.20032142925027,"temperature":13.6,"relativehumidity_2m":93,"windspeed_10m":14.5},{"datetime":"2024-10-09T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.348052794342024,"sunAzimuth":99.99195941586059,"temperature":13.7,"relativehumidity_2m":94,"windspeed_10m":15.3},{"datetime":"2024-10-09T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.43533401280622,"sunAzimuth":112.66096568522038,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":15.5},{"datetime":"2024-10-09T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.657312701408955,"sunAzimuth":126.98495418142949,"temperature":14.2,"relativehumidity_2m":93,"windspeed_10m":14.3},{"datetime":"2024-10-09T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.32941598688323,"sunAzimuth":143.71902473598436,"temperature":14.8,"relativehumidity_2m":90,"windspeed_10m":17.6},{"datetime":"2024-10-10T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.59106942107243,"sunAzimuth":163.13616076353475,"temperature":15.3,"relativehumidity_2m":89,"windspeed_10m":16.9},{"datetime":"2024-10-10T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.65007030458014,"sunAzimuth":-175.73407816523158,"temperature":15,"relativehumidity_2m":92,"windspeed_10m":15.1},{"datetime":"2024-10-10T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.26979987634531,"sunAzimuth":-155.060585251648,"temperature":14.9,"relativehumidity_2m":91,"windspeed_10m":15.3},{"datetime":"2024-10-10T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.96158240518052,"sunAzimuth":-136.66110792848332,"temperature":13.8,"relativehumidity_2m":97,"windspeed_10m":18.8},{"datetime":"2024-10-10T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.59647297796838,"sunAzimuth":-120.94117758387233,"temperature":12.4,"relativehumidity_2m":94,"windspeed_10m":25.6},{"datetime":"2024-10-10T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.97820409915596,"sunAzimuth":-107.35365077473413,"temperature":12.1,"relativehumidity_2m":92,"windspeed_10m":22.8},{"datetime":"2024-10-10T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.716704637744286,"sunAzimuth":-95.1044343497123,"temperature":12,"relativehumidity_2m":95,"windspeed_10m":19.5},{"datetime":"2024-10-10T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.275734703378654,"sunAzimuth":-83.4485894065161,"temperature":11.8,"relativehumidity_2m":93,"windspeed_10m":17.2},{"datetime":"2024-10-10T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":5.945826827444097,"sunAzimuth":-71.72880701980833,"temperature":12.1,"relativehumidity_2m":95,"windspeed_10m":16.3},{"datetime":"2024-10-10T09:00:00.000+02:00","dcPower":6.570788212467501,"power":5.256630569974001,"sunTilt":14.541778076840487,"sunAzimuth":-59.34793757422427,"temperature":12.1,"relativehumidity_2m":94,"windspeed_10m":14.7},{"datetime":"2024-10-10T10:00:00.000+02:00","dcPower":19.246776201607496,"power":15.397420961285997,"sunTilt":22.041831409435048,"sunAzimuth":-45.7728048820313,"temperature":12.2,"relativehumidity_2m":92,"windspeed_10m":15},{"datetime":"2024-10-10T11:00:00.000+02:00","dcPower":35.79458163940749,"power":28.635665311525994,"sunTilt":27.885174335468335,"sunAzimuth":-30.63663127573816,"temperature":12.5,"relativehumidity_2m":92,"windspeed_10m":15.7},{"datetime":"2024-10-10T12:00:00.000+02:00","dcPower":212.3973794995887,"power":169.91790359967098,"sunTilt":31.46827419380231,"sunAzimuth":-13.972554382839954,"temperature":12.8,"relativehumidity_2m":89,"windspeed_10m":18.5},{"datetime":"2024-10-10T13:00:00.000+02:00","dcPower":107.04232470086383,"power":85.63385976069107,"sunTilt":32.31297830760232,"sunAzimuth":3.5651067967559134,"temperature":12.3,"relativehumidity_2m":91,"windspeed_10m":17.7},{"datetime":"2024-10-10T14:00:00.000+02:00","dcPower":260.1484069522973,"power":208.11872556183786,"sunTilt":30.29076264178465,"sunAzimuth":20.858636821509823,"temperature":13.1,"relativehumidity_2m":87,"windspeed_10m":16},{"datetime":"2024-10-10T15:00:00.000+02:00","dcPower":268.64868665722827,"power":214.91894932578262,"sunTilt":25.696518065354862,"sunAzimuth":36.92666158608413,"temperature":13.2,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-10T16:00:00.000+02:00","dcPower":187.07375066675246,"power":149.65900053340198,"sunTilt":19.087103602605772,"sunAzimuth":51.382837136526646,"temperature":13,"relativehumidity_2m":81,"windspeed_10m":15.1},{"datetime":"2024-10-10T17:00:00.000+02:00","dcPower":116.12954417192189,"power":92.90363533753752,"sunTilt":11.060531491249431,"sunAzimuth":64.39321702308736,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":13.3},{"datetime":"2024-10-10T18:00:00.000+02:00","dcPower":79.50227134729688,"power":63.60181707783751,"sunTilt":2.1392339596675116,"sunAzimuth":76.4139573135511,"temperature":12.3,"relativehumidity_2m":79,"windspeed_10m":12.2},{"datetime":"2024-10-10T19:00:00.000+02:00","dcPower":14.266554068707503,"power":11.413243254966003,"sunTilt":-7.240134220715796,"sunAzimuth":88.00936653945188,"temperature":11,"relativehumidity_2m":80,"windspeed_10m":7},{"datetime":"2024-10-10T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.684429132286564,"sunAzimuth":99.79837109714164,"temperature":10.6,"relativehumidity_2m":84,"windspeed_10m":5.2},{"datetime":"2024-10-10T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.780350237977437,"sunAzimuth":112.47374911967749,"temperature":10.1,"relativehumidity_2m":85,"windspeed_10m":4.3},{"datetime":"2024-10-10T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.01656707008997,"sunAzimuth":126.82157256673277,"temperature":10.2,"relativehumidity_2m":84,"windspeed_10m":9.2},{"datetime":"2024-10-10T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.704586491662205,"sunAzimuth":143.61159893587703,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-11T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.97491243886403,"sunAzimuth":163.128648637255,"temperature":9.3,"relativehumidity_2m":92,"windspeed_10m":10.8},{"datetime":"2024-10-11T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.02467456169168,"sunAzimuth":-175.6141848122969,"temperature":8.9,"relativehumidity_2m":94,"windspeed_10m":8.7},{"datetime":"2024-10-11T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.61635732682025,"sunAzimuth":-154.8336646265192,"temperature":8.3,"relativehumidity_2m":91,"windspeed_10m":2.9},{"datetime":"2024-10-11T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.27297819203817,"sunAzimuth":-136.37626404685486,"temperature":8.3,"relativehumidity_2m":93,"windspeed_10m":3.6},{"datetime":"2024-10-11T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.877497329298222,"sunAzimuth":-120.63744534201388,"temperature":8.3,"relativehumidity_2m":95,"windspeed_10m":8.7},{"datetime":"2024-10-11T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.238894548955713,"sunAzimuth":-107.05006215076084,"temperature":8.3,"relativehumidity_2m":98,"windspeed_10m":6.5},{"datetime":"2024-10-11T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.96790216264699,"sunAzimuth":-94.80732853145587,"temperature":8.3,"relativehumidity_2m":96,"windspeed_10m":8.6},{"datetime":"2024-10-11T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.5276603134629134,"sunAzimuth":-83.1591411410959,"temperature":7.9,"relativehumidity_2m":95,"windspeed_10m":7.8},{"datetime":"2024-10-11T08:00:00.000+02:00","dcPower":0.24182794840750005,"power":0.19346235872600004,"sunTilt":5.683710585945252,"sunAzimuth":-71.44774052199065,"temperature":7.5,"relativehumidity_2m":96,"windspeed_10m":6.9},{"datetime":"2024-10-11T09:00:00.000+02:00","dcPower":30.4231615824675,"power":24.338529265974003,"sunTilt":14.260824082236146,"sunAzimuth":-59.0789120948653,"temperature":7.8,"relativehumidity_2m":93,"windspeed_10m":8.6},{"datetime":"2024-10-11T10:00:00.000+02:00","dcPower":110.45511291227001,"power":88.36409032981601,"sunTilt":21.734940400779433,"sunAzimuth":-45.525661029390804,"temperature":8.1,"relativehumidity_2m":89,"windspeed_10m":8.2},{"datetime":"2024-10-11T11:00:00.000+02:00","dcPower":179.76265080683,"power":143.81012064546402,"sunTilt":27.54875518315372,"sunAzimuth":-30.429767533564867,"temperature":9,"relativehumidity_2m":84,"windspeed_10m":7.6},{"datetime":"2024-10-11T12:00:00.000+02:00","dcPower":648.5202747579858,"power":518.8162198063886,"sunTilt":31.105090976135774,"sunAzimuth":-13.830431372703888,"temperature":9.8,"relativehumidity_2m":79,"windspeed_10m":7.4},{"datetime":"2024-10-11T13:00:00.000+02:00","dcPower":602.7210414765614,"power":482.1768331812491,"sunTilt":31.9332985100557,"sunAzimuth":3.6233914143032355,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":6.5},{"datetime":"2024-10-11T14:00:00.000+02:00","dcPower":612.5638197473248,"power":490.0510557978599,"sunTilt":29.908639212205873,"sunAzimuth":20.83241114244682,"temperature":10.8,"relativehumidity_2m":71,"windspeed_10m":5.1},{"datetime":"2024-10-11T15:00:00.000+02:00","dcPower":473.3573784004406,"power":378.6859027203525,"sunTilt":25.3232617284627,"sunAzimuth":36.83295334168066,"temperature":11.1,"relativehumidity_2m":69,"windspeed_10m":4.3},{"datetime":"2024-10-11T16:00:00.000+02:00","dcPower":275.36889679567673,"power":220.2951174365414,"sunTilt":18.727781114603793,"sunAzimuth":51.243806920166435,"temperature":11.2,"relativehumidity_2m":68,"windspeed_10m":4},{"datetime":"2024-10-11T17:00:00.000+02:00","dcPower":139.32802424290747,"power":111.46241939432599,"sunTilt":10.714725694488843,"sunAzimuth":64.22660182758023,"temperature":10.9,"relativehumidity_2m":71,"windspeed_10m":4.1},{"datetime":"2024-10-11T18:00:00.000+02:00","dcPower":74.31139897630752,"power":59.449119181046015,"sunTilt":1.803206213409536,"sunAzimuth":76.23115674859116,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":0.5},{"datetime":"2024-10-11T19:00:00.000+02:00","dcPower":14.798395647266878,"power":11.838716517813502,"sunTilt":-7.571835214892863,"sunAzimuth":87.81749031617603,"temperature":9,"relativehumidity_2m":79,"windspeed_10m":1.9},{"datetime":"2024-10-11T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.0181265759899,"sunAzimuth":99.60341073267222,"temperature":7.9,"relativehumidity_2m":83,"windspeed_10m":2.4},{"datetime":"2024-10-11T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.122656701936815,"sunAzimuth":112.28457164140065,"temperature":7,"relativehumidity_2m":85,"windspeed_10m":2.9},{"datetime":"2024-10-11T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.37321335134456,"sunAzimuth":126.65558045028693,"temperature":6.5,"relativehumidity_2m":86,"windspeed_10m":3},{"datetime":"2024-10-11T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.07743516418665,"sunAzimuth":143.5011643499793,"temperature":6,"relativehumidity_2m":88,"windspeed_10m":3.3},{"datetime":"2024-10-12T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.35684572865986,"sunAzimuth":163.11851362268473,"temperature":5.4,"relativehumidity_2m":89,"windspeed_10m":4.8},{"datetime":"2024-10-12T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.397705346785955,"sunAzimuth":-175.49580600360676,"temperature":5.1,"relativehumidity_2m":90,"windspeed_10m":4.7},{"datetime":"2024-10-12T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.961535734103165,"sunAzimuth":-154.60751324634,"temperature":4.9,"relativehumidity_2m":90,"windspeed_10m":5.1},{"datetime":"2024-10-12T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.58320673599567,"sunAzimuth":-136.09226665392447,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.1},{"datetime":"2024-10-12T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.157657336040163,"sunAzimuth":-120.33499159290702,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.499060815350038,"sunAzimuth":-106.7481901878237,"temperature":4.8,"relativehumidity_2m":91,"windspeed_10m":5},{"datetime":"2024-10-12T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.218886384957228,"sunAzimuth":-94.51232726794028,"temperature":4.6,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.77960910849284,"sunAzimuth":-82.87218040699524,"temperature":4.5,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T08:00:00.000+02:00","dcPower":0.24412114840750007,"power":0.19529691872600008,"sunTilt":5.421444806545091,"sunAzimuth":-71.16958809655219,"temperature":4.7,"relativehumidity_2m":92,"windspeed_10m":6.6},{"datetime":"2024-10-12T09:00:00.000+02:00","dcPower":45.32712998912,"power":36.261703991296,"sunTilt":13.979744388367903,"sunAzimuth":-58.8132832097472,"temperature":5.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-12T10:00:00.000+02:00","dcPower":128.75802772929686,"power":103.0064221834375,"sunTilt":21.42814200059926,"sunAzimuth":-45.28238230412684,"temperature":7.3,"relativehumidity_2m":87,"windspeed_10m":9.9},{"datetime":"2024-10-12T11:00:00.000+02:00","dcPower":184.27716056428,"power":147.421728451424,"sunTilt":27.21286115693797,"sunAzimuth":-30.227011858140273,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":10.4},{"datetime":"2024-10-12T12:00:00.000+02:00","dcPower":590.2420674249103,"power":472.19365393992825,"sunTilt":30.743014004100917,"sunAzimuth":-13.692125978099353,"temperature":10.1,"relativehumidity_2m":82,"windspeed_10m":12.3},{"datetime":"2024-10-12T13:00:00.000+02:00","dcPower":458.52745912891487,"power":366.82196730313194,"sunTilt":31.555283363449327,"sunAzimuth":3.6787683789403496,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.4},{"datetime":"2024-10-12T14:00:00.000+02:00","dcPower":412.6964962453449,"power":330.15719699627596,"sunTilt":29.52856227644022,"sunAzimuth":20.80440880713897,"temperature":11.2,"relativehumidity_2m":80,"windspeed_10m":11.3},{"datetime":"2024-10-12T15:00:00.000+02:00","dcPower":285.17838825344444,"power":228.14271060275556,"sunTilt":24.95225483898691,"sunAzimuth":36.7383136221432,"temperature":11.7,"relativehumidity_2m":80,"windspeed_10m":9.7},{"datetime":"2024-10-12T16:00:00.000+02:00","dcPower":170.7828071423425,"power":136.626245713874,"sunTilt":18.37082552430316,"sunAzimuth":51.10424752804786,"temperature":11.5,"relativehumidity_2m":82,"windspeed_10m":8.4},{"datetime":"2024-10-12T17:00:00.000+02:00","dcPower":111.50468126820752,"power":89.20374501456602,"sunTilt":10.371392509810583,"sunAzimuth":64.05951991866706,"temperature":11.1,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-12T18:00:00.000+02:00","dcPower":40.324461466607495,"power":32.259569173285996,"sunTilt":1.4697694157713943,"sunAzimuth":76.0477365277625,"temperature":10.5,"relativehumidity_2m":87,"windspeed_10m":8.4},{"datetime":"2024-10-12T19:00:00.000+02:00","dcPower":6.235591923470001,"power":4.9884735387760015,"sunTilt":-7.900822080898683,"sunAzimuth":87.62468730812897,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":8.5},{"datetime":"2024-10-12T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.349008833561726,"sunAzimuth":99.4070709783674,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":8.6},{"datetime":"2024-10-12T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.462114969564762,"sunAzimuth":112.09341375841275,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":9.2},{"datetime":"2024-10-12T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.7271125456906,"sunAzimuth":126.48693033734844,"temperature":9.7,"relativehumidity_2m":92,"windspeed_10m":8},{"datetime":"2024-10-12T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.447828525061254,"sunAzimuth":143.38762883737587,"temperature":9.4,"relativehumidity_2m":94,"windspeed_10m":7.3},{"datetime":"2024-10-13T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.73675190204195,"sunAzimuth":163.1056210331007,"temperature":9.3,"relativehumidity_2m":94,"windspeed_10m":6.5},{"datetime":"2024-10-13T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.76906905918878,"sunAzimuth":-175.37909348313417,"temperature":9.1,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-13T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.30526573902631,"sunAzimuth":-154.3822860597556,"temperature":9.5,"relativehumidity_2m":92,"windspeed_10m":10},{"datetime":"2024-10-13T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.89221927072221,"sunAzimuth":-135.80926949668307,"temperature":9.6,"relativehumidity_2m":90,"windspeed_10m":16.3},{"datetime":"2024-10-13T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.436918053719957,"sunAzimuth":-120.03396199414307,"temperature":8.8,"relativehumidity_2m":87,"windspeed_10m":18},{"datetime":"2024-10-13T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.75867399010402,"sunAzimuth":-106.44816955814555,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":15.5},{"datetime":"2024-10-13T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.469627601285604,"sunAzimuth":-94.21955537307969,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":17},{"datetime":"2024-10-13T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.0315450153141486,"sunAzimuth":-82.58782406375519,"temperature":7.2,"relativehumidity_2m":82,"windspeed_10m":16.2},{"datetime":"2024-10-13T08:00:00.000+02:00","dcPower":0.24248314840750004,"power":0.19398651872600003,"sunTilt":5.159076534957485,"sunAzimuth":-70.89445910846419,"temperature":6.7,"relativehumidity_2m":84,"windspeed_10m":16.8},{"datetime":"2024-10-13T09:00:00.000+02:00","dcPower":33.32620211254687,"power":26.6609616900375,"sunTilt":13.698600583367673,"sunAzimuth":-58.55115047580995,"temperature":6.9,"relativehumidity_2m":86,"windspeed_10m":15.5},{"datetime":"2024-10-13T10:00:00.000+02:00","dcPower":97.76344758208,"power":78.21075806566401,"sunTilt":21.12151408595382,"sunAzimuth":-45.043053146408546,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":22.7},{"datetime":"2024-10-13T11:00:00.000+02:00","dcPower":121.8090532421875,"power":97.44724259375,"sunTilt":26.877585132999467,"sunAzimuth":-30.028428502780528,"temperature":8.1,"relativehumidity_2m":83,"windspeed_10m":19.5},{"datetime":"2024-10-13T12:00:00.000+02:00","dcPower":283.55361802384414,"power":226.84289441907532,"sunTilt":30.382147107830978,"sunAzimuth":-13.557685005032592,"temperature":8.5,"relativehumidity_2m":82,"windspeed_10m":18.9},{"datetime":"2024-10-13T13:00:00.000+02:00","dcPower":460.29733045829306,"power":368.2378643666345,"sunTilt":31.17904416002473,"sunAzimuth":3.731196531846679,"temperature":9.4,"relativehumidity_2m":79,"windspeed_10m":19.1},{"datetime":"2024-10-13T14:00:00.000+02:00","dcPower":448.5623290202257,"power":358.8498632161806,"sunTilt":29.150649828823855,"sunAzimuth":20.774587486422366,"temperature":10.4,"relativehumidity_2m":75,"windspeed_10m":17.7},{"datetime":"2024-10-13T15:00:00.000+02:00","dcPower":389.502009045802,"power":311.60160723664166,"sunTilt":24.583621654336596,"sunAzimuth":36.642703763191776,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":17.6},{"datetime":"2024-10-13T16:00:00.000+02:00","dcPower":224.46273791123238,"power":179.57019032898592,"sunTilt":18.016365562424813,"sunAzimuth":50.96413031013026,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-13T17:00:00.000+02:00","dcPower":133.5301222976875,"power":106.82409783815001,"sunTilt":10.030663139779685,"sunAzimuth":63.89195411454739,"temperature":9.9,"relativehumidity_2m":79,"windspeed_10m":13.7},{"datetime":"2024-10-13T18:00:00.000+02:00","dcPower":55.5378030151875,"power":44.43024241215,"sunTilt":1.1390561362362333,"sunAzimuth":75.8636886240363,"temperature":9.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-13T19:00:00.000+02:00","dcPower":7.3238773566875,"power":5.85910188535,"sunTilt":-8.226960953264081,"sunAzimuth":87.43095436456028,"temperature":8.3,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-13T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.676940156857277,"sunAzimuth":99.2093472034457,"temperature":8.1,"relativehumidity_2m":87,"windspeed_10m":6.4},{"datetime":"2024-10-13T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.79858696849319,"sunAzimuth":111.90025906076745,"temperature":7.6,"relativehumidity_2m":90,"windspeed_10m":6.8},{"datetime":"2024-10-13T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.0781256664598,"sunAzimuth":126.3155777234557,"temperature":7.1,"relativehumidity_2m":90,"windspeed_10m":5},{"datetime":"2024-10-13T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.815632684929426,"sunAzimuth":143.2709019404984,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":2.6},{"datetime":"2024-10-14T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.11451301786215,"sunAzimuth":163.08983536166957,"temperature":6.3,"relativehumidity_2m":89,"windspeed_10m":2.4},{"datetime":"2024-10-14T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.138671848584934,"sunAzimuth":-175.2642016873541,"temperature":5.8,"relativehumidity_2m":89,"windspeed_10m":1.5},{"datetime":"2024-10-14T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.647478099904184,"sunAzimuth":-154.15814092455744,"temperature":5.3,"relativehumidity_2m":90,"windspeed_10m":4},{"datetime":"2024-10-14T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.1999672120048,"sunAzimuth":-135.52742842855076,"temperature":4.5,"relativehumidity_2m":93,"windspeed_10m":1.5},{"datetime":"2024-10-14T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.715244466780028,"sunAzimuth":-119.73450334119632,"temperature":4,"relativehumidity_2m":94,"windspeed_10m":4.5},{"datetime":"2024-10-14T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.01770469074795,"sunAzimuth":-106.15013533780943,"temperature":3.8,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.720095220219516,"sunAzimuth":-93.92913753819082,"temperature":4.2,"relativehumidity_2m":94,"windspeed_10m":5.8},{"datetime":"2024-10-14T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.283430715645694,"sunAzimuth":-82.30618839226909,"temperature":4.7,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.896654311121907,"sunAzimuth":-70.62246187023919,"temperature":5.1,"relativehumidity_2m":93,"windspeed_10m":4.3},{"datetime":"2024-10-14T09:00:00.000+02:00","dcPower":30.3524563671875,"power":24.281965093750003,"sunTilt":13.417455844515738,"sunAzimuth":-58.29261188464403,"temperature":5.6,"relativehumidity_2m":92,"windspeed_10m":3.8},{"datetime":"2024-10-14T10:00:00.000+02:00","dcPower":92.77502055675,"power":74.22001644539999,"sunTilt":20.815136015137117,"sunAzimuth":-44.807755986987566,"temperature":6.7,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-14T11:00:00.000+02:00","dcPower":141.1877117546875,"power":112.95016940375001,"sunTilt":26.543021174019792,"sunAzimuth":-29.83407952105961,"temperature":7.5,"relativehumidity_2m":85,"windspeed_10m":10.1},{"datetime":"2024-10-14T12:00:00.000+02:00","dcPower":124.394385388,"power":99.51550831040001,"sunTilt":30.022594925866507,"sunAzimuth":-13.427153142476177,"temperature":7.8,"relativehumidity_2m":87,"windspeed_10m":7.4},{"datetime":"2024-10-14T13:00:00.000+02:00","dcPower":173.39473735675,"power":138.7157898854,"sunTilt":30.804692641790414,"sunAzimuth":3.780636799023051,"temperature":8.7,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-14T14:00:00.000+02:00","dcPower":187.28123510318864,"power":149.8249880825509,"sunTilt":28.775019978770835,"sunAzimuth":20.742907009369926,"temperature":8.8,"relativehumidity_2m":83,"windspeed_10m":7.7},{"datetime":"2024-10-14T15:00:00.000+02:00","dcPower":166.78754287867994,"power":133.43003430294397,"sunTilt":24.217486258022436,"sunAzimuth":36.546087194880954,"temperature":8.8,"relativehumidity_2m":85,"windspeed_10m":6.5},{"datetime":"2024-10-14T16:00:00.000+02:00","dcPower":136.40005543196577,"power":109.12004434557262,"sunTilt":17.664529576020236,"sunAzimuth":50.82342857831685,"temperature":8.7,"relativehumidity_2m":88,"windspeed_10m":5.2},{"datetime":"2024-10-14T17:00:00.000+02:00","dcPower":93.42627162968749,"power":74.74101730375,"sunTilt":9.692668263612882,"sunAzimuth":63.7238891712367,"temperature":8.4,"relativehumidity_2m":90,"windspeed_10m":3.6},{"datetime":"2024-10-14T18:00:00.000+02:00","dcPower":43.279184403,"power":34.6233475224,"sunTilt":0.8111983279457368,"sunAzimuth":75.67900710147994,"temperature":8.2,"relativehumidity_2m":91,"windspeed_10m":1.8},{"datetime":"2024-10-14T19:00:00.000+02:00","dcPower":7.24105056675,"power":5.7928404534,"sunTilt":-8.5501186243532,"sunAzimuth":87.23629074336492,"temperature":7.9,"relativehumidity_2m":93,"windspeed_10m":0.4},{"datetime":"2024-10-14T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.00178541748869,"sunAzimuth":99.01023762777291,"temperature":7.4,"relativehumidity_2m":94,"windspeed_10m":1.1},{"datetime":"2024-10-14T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.131935065748255,"sunAzimuth":111.70509440468732,"temperature":6.7,"relativehumidity_2m":96,"windspeed_10m":1.1},{"datetime":"2024-10-14T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.426113802819536,"sunAzimuth":126.14148133580082,"temperature":5.8,"relativehumidity_2m":98,"windspeed_10m":0.4},{"datetime":"2024-10-14T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.180713378529184,"sunAzimuth":143.15089515757862,"temperature":5,"relativehumidity_2m":100,"windspeed_10m":0.7},{"datetime":"2024-10-15T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.49001057641694,"sunAzimuth":163.07102046565072,"temperature":4.3,"relativehumidity_2m":100,"windspeed_10m":1.4},{"datetime":"2024-10-15T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.506419588733614,"sunAzimuth":-175.15128768425117,"temperature":3.8,"relativehumidity_2m":100,"windspeed_10m":1.9},{"datetime":"2024-10-15T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.988103658491234,"sunAzimuth":-153.9352385869432,"temperature":3.2,"relativehumidity_2m":100,"windspeed_10m":2.6},{"datetime":"2024-10-15T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.50640211284843,"sunAzimuth":-135.24690137263536,"temperature":2.4,"relativehumidity_2m":100,"windspeed_10m":2.9},{"datetime":"2024-10-15T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.992601433814595,"sunAzimuth":-119.43676351875516,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.2},{"datetime":"2024-10-15T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.276123004228086,"sunAzimuth":-105.85422295783262,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.970257710966642,"sunAzimuth":-93.64119828341282,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.5352276078301,"sunAzimuth":-82.02738904900566,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.63422819396966,"sunAzimuth":-70.35370359566531,"temperature":2.3,"relativehumidity_2m":100,"windspeed_10m":4},{"datetime":"2024-10-15T09:00:00.000+02:00","dcPower":38.55145616875,"power":30.841164935000002,"sunTilt":13.136374944499462,"sunAzimuth":-58.037763824819606,"temperature":3.4,"relativehumidity_2m":98,"windspeed_10m":5},{"datetime":"2024-10-15T10:00:00.000+02:00","dcPower":109.08258472300001,"power":87.26606777840001,"sunTilt":20.50908861823578,"sunAzimuth":-44.57657122307415,"temperature":4.8,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-15T11:00:00.000+02:00","dcPower":165.55749042700003,"power":132.44599234160003,"sunTilt":26.20926450679345,"sunAzimuth":-29.644024765315514,"temperature":6.3,"relativehumidity_2m":91,"windspeed_10m":7.8},{"datetime":"2024-10-15T12:00:00.000+02:00","dcPower":200.0332693541875,"power":160.02661548335,"sunTilt":29.66446288060627,"sunAzimuth":-13.300572957692536,"temperature":8,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-15T13:00:00.000+02:00","dcPower":756.0843895583059,"power":604.8675116466447,"sunTilt":30.43234097439692,"sunAzimuth":3.8270522061418095,"temperature":9.7,"relativehumidity_2m":84,"windspeed_10m":10.9},{"datetime":"2024-10-15T14:00:00.000+02:00","dcPower":707.1329549971153,"power":565.7063639976923,"sunTilt":28.401790922595836,"sunAzimuth":20.709329389098386,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":11.9},{"datetime":"2024-10-15T15:00:00.000+02:00","dcPower":526.100183335248,"power":420.8801466681984,"sunTilt":23.853972526435296,"sunAzimuth":36.448429487078144,"temperature":11.2,"relativehumidity_2m":81,"windspeed_10m":11.9},{"datetime":"2024-10-15T16:00:00.000+02:00","dcPower":280.5106590390066,"power":224.4085272312053,"sunTilt":17.315445485866938,"sunAzimuth":50.682117672382816,"temperature":11,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-15T17:00:00.000+02:00","dcPower":118.147749552,"power":94.5181996416,"sunTilt":9.35753798215806,"sunAzimuth":63.55531186506398,"temperature":10.6,"relativehumidity_2m":82,"windspeed_10m":11.1},{"datetime":"2024-10-15T18:00:00.000+02:00","dcPower":63.10571387268751,"power":50.48457109815001,"sunTilt":0.48632726290863104,"sunAzimuth":75.49368820866877,"temperature":9.7,"relativehumidity_2m":85,"windspeed_10m":12.1},{"datetime":"2024-10-15T19:00:00.000+02:00","dcPower":13.241973932687499,"power":10.593579146149999,"sunTilt":-8.870162619560801,"sunAzimuth":87.04069822248259,"temperature":8.5,"relativehumidity_2m":88,"windspeed_10m":13.6},{"datetime":"2024-10-15T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.323410184499085,"sunAzimuth":98.80974345652376,"temperature":7.6,"relativehumidity_2m":91,"windspeed_10m":14.3},{"datetime":"2024-10-15T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.462022150796383,"sunAzimuth":111.50791010170794,"temperature":7.2,"relativehumidity_2m":91,"windspeed_10m":13.9},{"datetime":"2024-10-15T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.770938188604354,"sunAzimuth":125.96460338334036,"temperature":7,"relativehumidity_2m":91,"windspeed_10m":12.9},{"datetime":"2024-10-15T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.54293600102064,"sunAzimuth":143.02752222319543,"temperature":6.8,"relativehumidity_2m":89,"windspeed_10m":12.7},{"datetime":"2024-10-16T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.86312551477473,"sunAzimuth":163.04903976266138,"temperature":6.7,"relativehumidity_2m":86,"windspeed_10m":13.8},{"datetime":"2024-10-16T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.87221785059982,"sunAzimuth":-175.04051110279474,"temperature":6.8,"relativehumidity_2m":83,"windspeed_10m":15.6},{"datetime":"2024-10-16T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.32707330350441,"sunAzimuth":-153.71374265034802,"temperature":6.8,"relativehumidity_2m":80,"windspeed_10m":16.5},{"datetime":"2024-10-16T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.81147561550361,"sunAzimuth":-134.96784827714956,"temperature":7,"relativehumidity_2m":78,"windspeed_10m":16.1},{"datetime":"2024-10-16T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.26895363243685,"sunAzimuth":-119.1408914506387,"temperature":7.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-16T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.53389843309148,"sunAzimuth":-105.56056815727264,"temperature":7.4,"relativehumidity_2m":75,"windspeed_10m":14.3},{"datetime":"2024-10-16T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.220082554609817,"sunAzimuth":-93.355861910257,"temperature":7.4,"relativehumidity_2m":73,"windspeed_10m":14.3},{"datetime":"2024-10-16T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.786895768698711,"sunAzimuth":-81.75154101774274,"temperature":7.3,"relativehumidity_2m":71,"windspeed_10m":14.7},{"datetime":"2024-10-16T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.371849783596975,"sunAzimuth":-70.08829035405888,"temperature":7.2,"relativehumidity_2m":70,"windspeed_10m":14.8},{"datetime":"2024-10-16T09:00:00.000+02:00","dcPower":28.4527034491875,"power":22.76216275935,"sunTilt":12.855424254104108,"sunAzimuth":-57.78670104663053,"temperature":9.8,"relativehumidity_2m":72,"windspeed_10m":17},{"datetime":"2024-10-16T10:00:00.000+02:00","dcPower":82.400416875,"power":65.92033350000001,"sunTilt":20.203454185088123,"sunAzimuth":-44.34957719582011,"temperature":10.2,"relativehumidity_2m":75,"windspeed_10m":16.5},{"datetime":"2024-10-16T11:00:00.000+02:00","dcPower":129.82925219075,"power":103.8634017526,"sunTilt":25.87641150088954,"sunAzimuth":-29.45832187587005,"temperature":10.7,"relativehumidity_2m":77,"windspeed_10m":16.2},{"datetime":"2024-10-16T12:00:00.000+02:00","dcPower":176.5629584026875,"power":141.25036672215,"sunTilt":29.30785715204484,"sunAzimuth":-13.177984892004138,"temperature":11.6,"relativehumidity_2m":77,"windspeed_10m":15.7},{"datetime":"2024-10-16T13:00:00.000+02:00","dcPower":486.87760328361003,"power":389.50208262688807,"sunTilt":30.062101719417093,"sunAzimuth":3.870407893111919,"temperature":12.5,"relativehumidity_2m":75,"windspeed_10m":15.3},{"datetime":"2024-10-16T14:00:00.000+02:00","dcPower":471.13085183462414,"power":376.90468146769933,"sunTilt":28.03108091352568,"sunAzimuth":20.673818848950724,"temperature":13.2,"relativehumidity_2m":75,"windspeed_10m":14.8},{"datetime":"2024-10-16T15:00:00.000+02:00","dcPower":366.0504129403436,"power":292.8403303522749,"sunTilt":23.49320409080551,"sunAzimuth":36.34969840216598,"temperature":13.5,"relativehumidity_2m":77,"windspeed_10m":14.3},{"datetime":"2024-10-16T16:00:00.000+02:00","dcPower":216.91827490634043,"power":173.53461992507235,"sunTilt":16.969240740198778,"sunAzimuth":50.54017502787197,"temperature":13.5,"relativehumidity_2m":80,"windspeed_10m":13.7},{"datetime":"2024-10-16T17:00:00.000+02:00","dcPower":110.78138314075,"power":88.6251065126,"sunTilt":9.025401762418719,"sunAzimuth":63.386211071389155,"temperature":13.3,"relativehumidity_2m":82,"windspeed_10m":13.2},{"datetime":"2024-10-16T18:00:00.000+02:00","dcPower":54.4351348016875,"power":43.54810784135,"sunTilt":0.16457346744538273,"sunAzimuth":75.30773046735078,"temperature":13,"relativehumidity_2m":82,"windspeed_10m":13},{"datetime":"2024-10-16T19:00:00.000+02:00","dcPower":10.9590243,"power":8.76721944,"sunTilt":-9.186961271997145,"sunAzimuth":86.84418120594421,"temperature":12.6,"relativehumidity_2m":81,"windspeed_10m":12.7},{"datetime":"2024-10-16T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.64168080965683,"sunAzimuth":98.60786901950698,"temperature":12.2,"relativehumidity_2m":80,"windspeed_10m":12.2},{"datetime":"2024-10-16T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.788711717705176,"sunAzimuth":111.30870010109864,"temperature":12,"relativehumidity_2m":80,"windspeed_10m":11.8},{"datetime":"2024-10-16T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.112460277357016,"sunAzimuth":125.78490981561183,"temperature":11.8,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-16T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.9021656490531,"sunAzimuth":142.90069940402603,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":10.2},{"datetime":"2024-10-17T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.23373820466217,"sunAzimuth":163.02375644913673,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":9.4},{"datetime":"2024-10-17T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-48.2359718752176,"sunAzimuth":-174.9320340577077,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.6},{"datetime":"2024-10-17T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.66431793404074,"sunAzimuth":-153.4938195445769,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.2},{"datetime":"2024-10-17T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.115139405653565,"sunAzimuth":-134.69043107615582,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.9},{"datetime":"2024-10-17T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.54426550294801,"sunAzimuth":-118.84703704658551,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.79099983796868,"sunAzimuth":-105.2693069294498,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.469536195186127,"sunAzimuth":-93.07325245162968,"temperature":11.6,"relativehumidity_2m":82,"windspeed_10m":7.4},{"datetime":"2024-10-17T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-5.038393916921545,"sunAzimuth":-81.47875856041325,"temperature":11.4,"relativehumidity_2m":83,"windspeed_10m":7.3},{"datetime":"2024-10-17T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.109572239802723,"sunAzimuth":-69.8263270263417,"temperature":11.5,"relativehumidity_2m":83,"windspeed_10m":7.4},{"datetime":"2024-10-17T09:00:00.000+02:00","dcPower":25.602632300000003,"power":20.482105840000003,"sunTilt":12.574671747325786,"sunAzimuth":-57.53951661964237,"temperature":11.9,"relativehumidity_2m":83,"windspeed_10m":8},{"datetime":"2024-10-17T10:00:00.000+02:00","dcPower":76.2421036916875,"power":60.99368295335,"sunTilt":19.89831645004539,"sunAzimuth":-44.1268501705138,"temperature":12.5,"relativehumidity_2m":82,"windspeed_10m":9.7},{"datetime":"2024-10-17T11:00:00.000+02:00","dcPower":119.09589569674999,"power":95.2767165574,"sunTilt":25.54455964459646,"sunAzimuth":-29.277026273264024,"temperature":13.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-17T12:00:00.000+02:00","dcPower":153.93946101668752,"power":123.15156881335002,"sunTilt":28.952884649607554,"sunAzimuth":-13.059427258390235,"temperature":13.5,"relativehumidity_2m":83,"windspeed_10m":10.7},{"datetime":"2024-10-17T13:00:00.000+02:00","dcPower":248.22004161241605,"power":198.57603328993287,"sunTilt":29.694087805098004,"sunAzimuth":3.9106711264578404,"temperature":13.8,"relativehumidity_2m":85,"windspeed_10m":10.8},{"datetime":"2024-10-17T14:00:00.000+02:00","dcPower":229.15318902704783,"power":183.32255122163826,"sunTilt":27.663008228484166,"sunAzimuth":20.63634185579284,"temperature":14.2,"relativehumidity_2m":86,"windspeed_10m":10.5},{"datetime":"2024-10-17T15:00:00.000+02:00","dcPower":203.74729317905823,"power":162.9978345432466,"sunTilt":23.13530429931852,"sunAzimuth":36.24986394147597,"temperature":14.6,"relativehumidity_2m":87,"windspeed_10m":9.8},{"datetime":"2024-10-17T16:00:00.000+02:00","dcPower":161.97727115162724,"power":129.5818169213018,"sunTilt":16.626042266391202,"sunAzimuth":50.39758024284303,"temperature":14.9,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-17T17:00:00.000+02:00","dcPower":108.840563643,"power":87.07245091440001,"sunTilt":8.696388379725821,"sunAzimuth":63.21657784217465,"temperature":15.1,"relativehumidity_2m":89,"windspeed_10m":8.7},{"datetime":"2024-10-17T18:00:00.000+02:00","dcPower":53.53735983268749,"power":42.829887866149996,"sunTilt":-0.15393334821361332,"sunAzimuth":75.12113476396313,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-17T19:00:00.000+02:00","dcPower":9.524056729687501,"power":7.619245383750002,"sunTilt":-9.500383797971471,"sunAzimuth":86.64674682587622,"temperature":14.6,"relativehumidity_2m":92,"windspeed_10m":6.7},{"datetime":"2024-10-17T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.956464510241965,"sunAzimuth":98.40462190157557,"temperature":14.3,"relativehumidity_2m":93,"windspeed_10m":5.9},{"datetime":"2024-10-17T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-28.11186795253563,"sunAzimuth":111.10746217430679,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":5.4},{"datetime":"2024-10-17T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.45054181856139,"sunAzimuth":125.60237057929713,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":4.6},{"datetime":"2024-10-17T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.2582671659911,"sunAzimuth":142.77034580724174,"temperature":14.2,"relativehumidity_2m":95,"windspeed_10m":4.4}],[{"datetime":"2024-10-06T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.03892891605494,"sunAzimuth":163.14263622624128,"temperature":7,"relativehumidity_2m":88,"windspeed_10m":7.9},{"datetime":"2024-10-06T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.13778324543035,"sunAzimuth":-176.22585898864278,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":6.8},{"datetime":"2024-10-06T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.87117274960936,"sunAzimuth":-155.9729639229445,"temperature":6,"relativehumidity_2m":91,"windspeed_10m":5.9},{"datetime":"2024-10-06T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.70531092777589,"sunAzimuth":-137.8059489226708,"temperature":5.5,"relativehumidity_2m":92,"windspeed_10m":5.1},{"datetime":"2024-10-06T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.46443142893699,"sunAzimuth":-122.16602054266892,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.2},{"datetime":"2024-10-06T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-20.930765133481234,"sunAzimuth":-108.58249513077881,"temperature":4.9,"relativehumidity_2m":93,"windspeed_10m":5.8},{"datetime":"2024-10-06T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.710347315474053,"sunAzimuth":-96.31140508589108,"temperature":5.1,"relativehumidity_2m":92,"windspeed_10m":6.5},{"datetime":"2024-10-06T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.2689443207496223,"sunAzimuth":-84.62890292197706,"temperature":5.3,"relativehumidity_2m":92,"windspeed_10m":7.6},{"datetime":"2024-10-06T08:00:00.000+02:00","dcPower":3.3713875541106932,"power":2.697110043288555,"sunTilt":6.991906328571172,"sunAzimuth":-72.87999206290318,"temperature":5.5,"relativehumidity_2m":91,"windspeed_10m":8.4},{"datetime":"2024-10-06T09:00:00.000+02:00","dcPower":70.82484417927611,"power":56.65987534342089,"sunTilt":15.663160391528187,"sunAzimuth":-60.45596163553978,"temperature":6.3,"relativehumidity_2m":90,"windspeed_10m":9.3},{"datetime":"2024-10-06T10:00:00.000+02:00","dcPower":143.48119878382155,"power":114.78495902705725,"sunTilt":23.268816289666535,"sunAzimuth":-46.79827360798693,"temperature":8,"relativehumidity_2m":85,"windspeed_10m":11.6},{"datetime":"2024-10-06T11:00:00.000+02:00","dcPower":811.9852773713835,"power":649.5882218971069,"sunTilt":29.234287239795027,"sunAzimuth":-31.503805204051176,"temperature":9.5,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-06T12:00:00.000+02:00","dcPower":670.9876969713171,"power":536.7901575770537,"sunTilt":32.93002248275174,"sunAzimuth":-14.578212396799534,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":16.3},{"datetime":"2024-10-06T13:00:00.000+02:00","dcPower":702.4696779546789,"power":561.9757423637432,"sunTilt":33.84613522696556,"sunAzimuth":3.3037874055175505,"temperature":10.9,"relativehumidity_2m":82,"windspeed_10m":16.9},{"datetime":"2024-10-06T14:00:00.000+02:00","dcPower":644.8630403406012,"power":515.890432272481,"sunTilt":31.83736693728352,"sunAzimuth":20.94669333759787,"temperature":12.6,"relativehumidity_2m":74,"windspeed_10m":17.1},{"datetime":"2024-10-06T15:00:00.000+02:00","dcPower":329.8924014292663,"power":263.913921143413,"sunTilt":27.209548486852757,"sunAzimuth":37.29302345489315,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":17.8},{"datetime":"2024-10-06T16:00:00.000+02:00","dcPower":256.92857639701907,"power":205.54286111761527,"sunTilt":20.54547686442047,"sunAzimuth":51.93430385037965,"temperature":13.1,"relativehumidity_2m":73,"windspeed_10m":17.8},{"datetime":"2024-10-06T17:00:00.000+02:00","dcPower":232.7996743539756,"power":186.2397394831805,"sunTilt":12.4658413410018,"sunAzimuth":65.05541740712634,"temperature":12.6,"relativehumidity_2m":72,"windspeed_10m":15.3},{"datetime":"2024-10-06T18:00:00.000+02:00","dcPower":123.82855203865259,"power":99.06284163092208,"sunTilt":3.5065849097251456,"sunAzimuth":77.13919140741508,"temperature":11.9,"relativehumidity_2m":76,"windspeed_10m":14.8},{"datetime":"2024-10-06T19:00:00.000+02:00","dcPower":24.756413134229224,"power":19.80513050738338,"sunTilt":-5.8888854618235795,"sunAzimuth":88.76774476136781,"temperature":11.1,"relativehumidity_2m":79,"windspeed_10m":15.1},{"datetime":"2024-10-06T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.324219942418523,"sunAzimuth":100.56460329657087,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.2},{"datetime":"2024-10-06T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.385415066790006,"sunAzimuth":113.21108724923529,"temperature":10.3,"relativehumidity_2m":81,"windspeed_10m":13.4},{"datetime":"2024-10-06T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.56528997863786,"sunAzimuth":127.45995077522508,"temperature":10.3,"relativehumidity_2m":83,"windspeed_10m":13.4},{"datetime":"2024-10-06T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.19130152908581,"sunAzimuth":144.02419079232183,"temperature":10.7,"relativehumidity_2m":83,"windspeed_10m":13},{"datetime":"2024-10-07T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.4292477470776,"sunAzimuth":163.14429087891105,"temperature":11,"relativehumidity_2m":83,"windspeed_10m":13.6},{"datetime":"2024-10-07T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.517749495901406,"sunAzimuth":-176.10136944486192,"temperature":11,"relativehumidity_2m":87,"windspeed_10m":11.2},{"datetime":"2024-10-07T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.22254930467857,"sunAzimuth":-155.74445709329385,"temperature":10.9,"relativehumidity_2m":91,"windspeed_10m":9.8},{"datetime":"2024-10-07T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.020882886444426,"sunAzimuth":-137.5192133151141,"temperature":11,"relativehumidity_2m":93,"windspeed_10m":8.7},{"datetime":"2024-10-07T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-29.748563322135755,"sunAzimuth":-121.8586066543482,"temperature":11.4,"relativehumidity_2m":94,"windspeed_10m":8.9},{"datetime":"2024-10-07T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.193270783038106,"sunAzimuth":-108.27337691467278,"temperature":11.5,"relativehumidity_2m":95,"windspeed_10m":11},{"datetime":"2024-10-07T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-11.962117012521006,"sunAzimuth":-96.00713008699226,"temperature":12,"relativehumidity_2m":94,"windspeed_10m":8.4},{"datetime":"2024-10-07T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.520439907672165,"sunAzimuth":-84.33068309080377,"temperature":12.2,"relativehumidity_2m":95,"windspeed_10m":9.3},{"datetime":"2024-10-07T08:00:00.000+02:00","dcPower":1.4871743864343177,"power":1.189739509147454,"sunTilt":6.730829735879827,"sunAzimuth":-72.58838243599898,"temperature":12.7,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-07T09:00:00.000+02:00","dcPower":30.392000747748977,"power":24.313600598199184,"sunTilt":15.383293627086166,"sunAzimuth":-60.174375329765816,"temperature":13.3,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-07T10:00:00.000+02:00","dcPower":122.83253304737609,"power":98.26602643790088,"sunTilt":22.96230371458641,"sunAzimuth":-46.53655309716923,"temperature":14.4,"relativehumidity_2m":91,"windspeed_10m":8.9},{"datetime":"2024-10-07T11:00:00.000+02:00","dcPower":135.97403551360443,"power":108.77922841088355,"sunTilt":28.896671975172765,"sunAzimuth":-31.281195510232457,"temperature":15.2,"relativehumidity_2m":91,"windspeed_10m":10.9},{"datetime":"2024-10-07T12:00:00.000+02:00","dcPower":251.53152684611086,"power":201.2252214768887,"sunTilt":32.56343562699629,"sunAzimuth":-14.421329305847635,"temperature":16,"relativehumidity_2m":89,"windspeed_10m":8.4},{"datetime":"2024-10-07T13:00:00.000+02:00","dcPower":487.9224729067913,"power":390.3379783254331,"sunTilt":33.46089993111447,"sunAzimuth":3.3732496911031458,"temperature":17,"relativehumidity_2m":82,"windspeed_10m":10.5},{"datetime":"2024-10-07T14:00:00.000+02:00","dcPower":645.7510235050102,"power":516.6008188040081,"sunTilt":31.448234246769605,"sunAzimuth":20.927108911856653,"temperature":18,"relativehumidity_2m":75,"windspeed_10m":9.8},{"datetime":"2024-10-07T15:00:00.000+02:00","dcPower":310.6413374640256,"power":248.5130699712205,"sunTilt":26.828539273003113,"sunAzimuth":37.20261442198752,"temperature":18.6,"relativehumidity_2m":70,"windspeed_10m":5.8},{"datetime":"2024-10-07T16:00:00.000+02:00","dcPower":256.82161295176576,"power":205.45729036141262,"sunTilt":20.17798028996772,"sunAzimuth":51.797067137147856,"temperature":18.9,"relativehumidity_2m":70,"windspeed_10m":7.4},{"datetime":"2024-10-07T17:00:00.000+02:00","dcPower":229.1217228030218,"power":183.29737824241747,"sunTilt":12.11146577521795,"sunAzimuth":64.89046144901918,"temperature":18.4,"relativehumidity_2m":72,"windspeed_10m":7.2},{"datetime":"2024-10-07T18:00:00.000+02:00","dcPower":103.54709914126633,"power":82.83767931301307,"sunTilt":3.1615292467832945,"sunAzimuth":76.95875071433478,"temperature":17.4,"relativehumidity_2m":74,"windspeed_10m":9.5},{"datetime":"2024-10-07T19:00:00.000+02:00","dcPower":22.74012496621281,"power":18.19209997297025,"sunTilt":-6.2300935503595385,"sunAzimuth":88.57949984494998,"temperature":16.4,"relativehumidity_2m":79,"windspeed_10m":7.6},{"datetime":"2024-10-07T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-15.667810875617224,"sunAzimuth":100.37506201699385,"temperature":15.4,"relativehumidity_2m":87,"windspeed_10m":9.2},{"datetime":"2024-10-07T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-24.737727405113922,"sunAzimuth":113.02960837119129,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":10.9},{"datetime":"2024-10-07T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.93153539182831,"sunAzimuth":127.30408938398604,"temperature":14.5,"relativehumidity_2m":94,"windspeed_10m":10.7},{"datetime":"2024-10-07T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.572641175501424,"sunAzimuth":143.925225436442,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":11.6},{"datetime":"2024-10-08T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.818121034022454,"sunAzimuth":163.14384998115503,"temperature":14.7,"relativehumidity_2m":94,"windspeed_10m":15.8},{"datetime":"2024-10-08T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.8965146817323,"sunAzimuth":-175.97781528741945,"temperature":14.8,"relativehumidity_2m":93,"windspeed_10m":14},{"datetime":"2024-10-08T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.572826067170936,"sunAzimuth":-155.51612909180187,"temperature":15.4,"relativehumidity_2m":94,"windspeed_10m":11.2},{"datetime":"2024-10-08T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.33548533853669,"sunAzimuth":-137.23273090041528,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":12.2},{"datetime":"2024-10-08T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.031970973409337,"sunAzimuth":-121.55190085899577,"temperature":15.4,"relativehumidity_2m":93,"windspeed_10m":11.2},{"datetime":"2024-10-08T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.45536424486834,"sunAzimuth":-107.96544161465735,"temperature":15.8,"relativehumidity_2m":94,"windspeed_10m":10},{"datetime":"2024-10-08T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.213784351950519,"sunAzimuth":-95.70446004731738,"temperature":15.8,"relativehumidity_2m":93,"windspeed_10m":10.8},{"datetime":"2024-10-08T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-2.772091353585381,"sunAzimuth":-84.03447848078322,"temperature":15.7,"relativehumidity_2m":93,"windspeed_10m":10.5},{"datetime":"2024-10-08T08:00:00.000+02:00","dcPower":1.1774588428322112,"power":0.9419670742657691,"sunTilt":6.469429830924286,"sunAzimuth":-72.29923977528891,"temperature":15.7,"relativehumidity_2m":90,"windspeed_10m":10.2},{"datetime":"2024-10-08T09:00:00.000+02:00","dcPower":61.738481173216655,"power":49.390784938573326,"sunTilt":15.10307050684314,"sunAzimuth":-59.895772492079,"temperature":15.7,"relativehumidity_2m":89,"windspeed_10m":8.3},{"datetime":"2024-10-08T10:00:00.000+02:00","dcPower":165.74955432771506,"power":132.59964346217205,"sunTilt":22.655587204347487,"sunAzimuth":-46.27834028428006,"temperature":16.3,"relativehumidity_2m":86,"windspeed_10m":10.6},{"datetime":"2024-10-08T11:00:00.000+02:00","dcPower":302.02175381156064,"power":241.61740304924854,"sunTilt":28.55922255887939,"sunAzimuth":-31.06241498925654,"temperature":17,"relativehumidity_2m":85,"windspeed_10m":9},{"datetime":"2024-10-08T12:00:00.000+02:00","dcPower":407.300701925971,"power":325.84056154077683,"sunTilt":32.19754822667087,"sunAzimuth":-14.26805544520534,"temperature":17.6,"relativehumidity_2m":82,"windspeed_10m":13.1},{"datetime":"2024-10-08T13:00:00.000+02:00","dcPower":587.8235107579072,"power":470.25880860632583,"sunTilt":33.07688907994966,"sunAzimuth":3.4399896103944245,"temperature":18.3,"relativehumidity_2m":73,"windspeed_10m":12.7},{"datetime":"2024-10-08T14:00:00.000+02:00","dcPower":499.10887945306024,"power":399.28710356244824,"sunTilt":31.060677746554727,"sunAzimuth":20.90593811884074,"temperature":18.2,"relativehumidity_2m":71,"windspeed_10m":13.2},{"datetime":"2024-10-08T15:00:00.000+02:00","dcPower":344.3210351561571,"power":275.4568281249257,"sunTilt":26.44928138282813,"sunAzimuth":37.11144846293623,"temperature":18.1,"relativehumidity_2m":71,"windspeed_10m":13.3},{"datetime":"2024-10-08T16:00:00.000+02:00","dcPower":247.53117087126986,"power":198.0249366970159,"sunTilt":19.81233269039211,"sunAzimuth":51.65943401571543,"temperature":17.4,"relativehumidity_2m":73,"windspeed_10m":9.2},{"datetime":"2024-10-08T17:00:00.000+02:00","dcPower":176.7268760826156,"power":141.3815008660925,"sunTilt":11.759033809196055,"sunAzimuth":64.72512518734406,"temperature":16.9,"relativehumidity_2m":75,"windspeed_10m":6.3},{"datetime":"2024-10-08T18:00:00.000+02:00","dcPower":100.76280838553514,"power":80.61024670842812,"sunTilt":2.818529310708217,"sunAzimuth":76.77774147088243,"temperature":16,"relativehumidity_2m":75,"windspeed_10m":8.6},{"datetime":"2024-10-08T19:00:00.000+02:00","dcPower":22.833330533924155,"power":18.266664427139325,"sunTilt":-6.569128186596377,"sunAzimuth":88.39036262615889,"temperature":15,"relativehumidity_2m":82,"windspeed_10m":5.8},{"datetime":"2024-10-08T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.00913432264249,"sunAzimuth":100.18418560559792,"temperature":14.5,"relativehumidity_2m":89,"windspeed_10m":4.7},{"datetime":"2024-10-08T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.087746753502067,"sunAzimuth":112.84624373263145,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":8.4},{"datetime":"2024-10-08T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.29558919415396,"sunAzimuth":127.1457755347392,"temperature":14,"relativehumidity_2m":88,"windspeed_10m":8.6},{"datetime":"2024-10-08T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.95205668980692,"sunAzimuth":143.82353531827584,"temperature":13,"relativehumidity_2m":91,"windspeed_10m":7.2},{"datetime":"2024-10-09T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.20543351779722,"sunAzimuth":163.1411836959831,"temperature":12.6,"relativehumidity_2m":92,"windspeed_10m":7.8},{"datetime":"2024-10-09T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.27398595213309,"sunAzimuth":-175.8553370630724,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.5},{"datetime":"2024-10-09T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.92193289241474,"sunAzimuth":-155.28812310014723,"temperature":12.7,"relativehumidity_2m":92,"windspeed_10m":10.5},{"datetime":"2024-10-09T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.64906836752005,"sunAzimuth":-136.94664669208646,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":12.3},{"datetime":"2024-10-09T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.314619206373006,"sunAzimuth":-121.24604384005964,"temperature":12.9,"relativehumidity_2m":91,"windspeed_10m":10.2},{"datetime":"2024-10-09T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.71701795569338,"sunAzimuth":-107.65882183814558,"temperature":13.2,"relativehumidity_2m":90,"windspeed_10m":12.9},{"datetime":"2024-10-09T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.465322674764078,"sunAzimuth":-95.40351983003244,"temperature":13.3,"relativehumidity_2m":89,"windspeed_10m":13.5},{"datetime":"2024-10-09T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.023867147662328,"sunAzimuth":-83.74040781223535,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":13.2},{"datetime":"2024-10-09T08:00:00.000+02:00","dcPower":0.29725475849939703,"power":0.23780380679951763,"sunTilt":6.207747953736028,"sunAzimuth":-72.01267721735641,"temperature":12.9,"relativehumidity_2m":92,"windspeed_10m":11.8},{"datetime":"2024-10-09T09:00:00.000+02:00","dcPower":16.039424714571386,"power":12.83153977165711,"sunTilt":14.82254636430179,"sunAzimuth":-59.62025856421435,"temperature":12.8,"relativehumidity_2m":93,"windspeed_10m":11.8},{"datetime":"2024-10-09T10:00:00.000+02:00","dcPower":51.0745453462637,"power":40.859636277010964,"sunTilt":22.348738640203063,"sunAzimuth":-46.023727432629336,"temperature":13.1,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T11:00:00.000+02:00","dcPower":104.15753243201114,"power":83.32602594560892,"sunTilt":28.22202694317921,"sunAzimuth":-30.847536638720733,"temperature":13.7,"relativehumidity_2m":92,"windspeed_10m":12.8},{"datetime":"2024-10-09T12:00:00.000+02:00","dcPower":157.65994959715988,"power":126.12795967772792,"sunTilt":31.832460658762763,"sunAzimuth":-14.118446086753368,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":12},{"datetime":"2024-10-09T13:00:00.000+02:00","dcPower":143.9780319305286,"power":115.18242554442288,"sunTilt":32.69421193791607,"sunAzimuth":3.503957762689472,"temperature":14.2,"relativehumidity_2m":90,"windspeed_10m":11.5},{"datetime":"2024-10-09T14:00:00.000+02:00","dcPower":106.12732760501824,"power":84.9018620840146,"sunTilt":30.674814714273392,"sunAzimuth":20.883130296493302,"temperature":13.9,"relativehumidity_2m":91,"windspeed_10m":13.7},{"datetime":"2024-10-09T15:00:00.000+02:00","dcPower":59.78263600805569,"power":47.82610880644455,"sunTilt":26.07189945210854,"sunAzimuth":37.01947906146245,"temperature":13.8,"relativehumidity_2m":92,"windspeed_10m":13.8},{"datetime":"2024-10-09T16:00:00.000+02:00","dcPower":46.56073709140872,"power":37.24858967312698,"sunTilt":19.448663917806936,"sunAzimuth":51.521368716140636,"temperature":13.8,"relativehumidity_2m":91,"windspeed_10m":14.5},{"datetime":"2024-10-09T17:00:00.000+02:00","dcPower":31.078962623371577,"power":24.863170098697264,"sunTilt":11.408678227135676,"sunAzimuth":64.55938454107346,"temperature":13.5,"relativehumidity_2m":90,"windspeed_10m":15.8},{"datetime":"2024-10-09T18:00:00.000+02:00","dcPower":18.522376821489427,"power":14.817901457191542,"sunTilt":2.4777195309842384,"sunAzimuth":76.59614824640437,"temperature":13.4,"relativehumidity_2m":92,"windspeed_10m":13.4},{"datetime":"2024-10-09T19:00:00.000+02:00","dcPower":4.150380638374255,"power":3.3203045106994042,"sunTilt":-6.905853552820109,"sunAzimuth":88.20032142925027,"temperature":13.6,"relativehumidity_2m":93,"windspeed_10m":14.5},{"datetime":"2024-10-09T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.348052794342024,"sunAzimuth":99.99195941586059,"temperature":13.7,"relativehumidity_2m":94,"windspeed_10m":15.3},{"datetime":"2024-10-09T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.43533401280622,"sunAzimuth":112.66096568522038,"temperature":14,"relativehumidity_2m":93,"windspeed_10m":15.5},{"datetime":"2024-10-09T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-33.657312701408955,"sunAzimuth":126.98495418142949,"temperature":14.2,"relativehumidity_2m":93,"windspeed_10m":14.3},{"datetime":"2024-10-09T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.32941598688323,"sunAzimuth":143.71902473598436,"temperature":14.8,"relativehumidity_2m":90,"windspeed_10m":17.6},{"datetime":"2024-10-10T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.59106942107243,"sunAzimuth":163.13616076353475,"temperature":15.3,"relativehumidity_2m":89,"windspeed_10m":16.9},{"datetime":"2024-10-10T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.65007030458014,"sunAzimuth":-175.73407816523158,"temperature":15,"relativehumidity_2m":92,"windspeed_10m":15.1},{"datetime":"2024-10-10T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.26979987634531,"sunAzimuth":-155.060585251648,"temperature":14.9,"relativehumidity_2m":91,"windspeed_10m":15.3},{"datetime":"2024-10-10T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-37.96158240518052,"sunAzimuth":-136.66110792848332,"temperature":13.8,"relativehumidity_2m":97,"windspeed_10m":18.8},{"datetime":"2024-10-10T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.59647297796838,"sunAzimuth":-120.94117758387233,"temperature":12.4,"relativehumidity_2m":94,"windspeed_10m":25.6},{"datetime":"2024-10-10T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-21.97820409915596,"sunAzimuth":-107.35365077473413,"temperature":12.1,"relativehumidity_2m":92,"windspeed_10m":22.8},{"datetime":"2024-10-10T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.716704637744286,"sunAzimuth":-95.1044343497123,"temperature":12,"relativehumidity_2m":95,"windspeed_10m":19.5},{"datetime":"2024-10-10T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.275734703378654,"sunAzimuth":-83.4485894065161,"temperature":11.8,"relativehumidity_2m":93,"windspeed_10m":17.2},{"datetime":"2024-10-10T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":5.945826827444097,"sunAzimuth":-71.72880701980833,"temperature":12.1,"relativehumidity_2m":95,"windspeed_10m":16.3},{"datetime":"2024-10-10T09:00:00.000+02:00","dcPower":8.210884526192709,"power":6.568707620954168,"sunTilt":14.541778076840487,"sunAzimuth":-59.34793757422427,"temperature":12.1,"relativehumidity_2m":94,"windspeed_10m":14.7},{"datetime":"2024-10-10T10:00:00.000+02:00","dcPower":24.095319708538952,"power":19.276255766831163,"sunTilt":22.041831409435048,"sunAzimuth":-45.7728048820313,"temperature":12.2,"relativehumidity_2m":92,"windspeed_10m":15},{"datetime":"2024-10-10T11:00:00.000+02:00","dcPower":44.805984641630765,"power":35.84478771330461,"sunTilt":27.885174335468335,"sunAzimuth":-30.63663127573816,"temperature":12.5,"relativehumidity_2m":92,"windspeed_10m":15.7},{"datetime":"2024-10-10T12:00:00.000+02:00","dcPower":256.4594108943556,"power":205.16752871548448,"sunTilt":31.46827419380231,"sunAzimuth":-13.972554382839954,"temperature":12.8,"relativehumidity_2m":89,"windspeed_10m":18.5},{"datetime":"2024-10-10T13:00:00.000+02:00","dcPower":135.73154317876075,"power":108.5852345430086,"sunTilt":32.31297830760232,"sunAzimuth":3.5651067967559134,"temperature":12.3,"relativehumidity_2m":91,"windspeed_10m":17.7},{"datetime":"2024-10-10T14:00:00.000+02:00","dcPower":357.83816763055387,"power":286.27053410444313,"sunTilt":30.29076264178465,"sunAzimuth":20.858636821509823,"temperature":13.1,"relativehumidity_2m":87,"windspeed_10m":16},{"datetime":"2024-10-10T15:00:00.000+02:00","dcPower":210.28584459785964,"power":168.22867567828771,"sunTilt":25.696518065354862,"sunAzimuth":36.92666158608413,"temperature":13.2,"relativehumidity_2m":86,"windspeed_10m":15.1},{"datetime":"2024-10-10T16:00:00.000+02:00","dcPower":186.22904339805515,"power":148.98323471844412,"sunTilt":19.087103602605772,"sunAzimuth":51.382837136526646,"temperature":13,"relativehumidity_2m":81,"windspeed_10m":15.1},{"datetime":"2024-10-10T17:00:00.000+02:00","dcPower":144.10037101001555,"power":115.28029680801245,"sunTilt":11.060531491249431,"sunAzimuth":64.39321702308736,"temperature":12.8,"relativehumidity_2m":75,"windspeed_10m":13.3},{"datetime":"2024-10-10T18:00:00.000+02:00","dcPower":97.59079650367762,"power":78.0726372029421,"sunTilt":2.1392339596675116,"sunAzimuth":76.4139573135511,"temperature":12.3,"relativehumidity_2m":79,"windspeed_10m":12.2},{"datetime":"2024-10-10T19:00:00.000+02:00","dcPower":17.754377568726305,"power":14.203502054981044,"sunTilt":-7.240134220715796,"sunAzimuth":88.00936653945188,"temperature":11,"relativehumidity_2m":80,"windspeed_10m":7},{"datetime":"2024-10-10T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-16.684429132286564,"sunAzimuth":99.79837109714164,"temperature":10.6,"relativehumidity_2m":84,"windspeed_10m":5.2},{"datetime":"2024-10-10T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-25.780350237977437,"sunAzimuth":112.47374911967749,"temperature":10.1,"relativehumidity_2m":85,"windspeed_10m":4.3},{"datetime":"2024-10-10T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.01656707008997,"sunAzimuth":126.82157256673277,"temperature":10.2,"relativehumidity_2m":84,"windspeed_10m":9.2},{"datetime":"2024-10-10T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.704586491662205,"sunAzimuth":143.61159893587703,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-11T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.97491243886403,"sunAzimuth":163.128648637255,"temperature":9.3,"relativehumidity_2m":92,"windspeed_10m":10.8},{"datetime":"2024-10-11T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.02467456169168,"sunAzimuth":-175.6141848122969,"temperature":8.9,"relativehumidity_2m":94,"windspeed_10m":8.7},{"datetime":"2024-10-11T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.61635732682025,"sunAzimuth":-154.8336646265192,"temperature":8.3,"relativehumidity_2m":91,"windspeed_10m":2.9},{"datetime":"2024-10-11T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.27297819203817,"sunAzimuth":-136.37626404685486,"temperature":8.3,"relativehumidity_2m":93,"windspeed_10m":3.6},{"datetime":"2024-10-11T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-30.877497329298222,"sunAzimuth":-120.63744534201388,"temperature":8.3,"relativehumidity_2m":95,"windspeed_10m":8.7},{"datetime":"2024-10-11T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.238894548955713,"sunAzimuth":-107.05006215076084,"temperature":8.3,"relativehumidity_2m":98,"windspeed_10m":6.5},{"datetime":"2024-10-11T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-12.96790216264699,"sunAzimuth":-94.80732853145587,"temperature":8.3,"relativehumidity_2m":96,"windspeed_10m":8.6},{"datetime":"2024-10-11T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.5276603134629134,"sunAzimuth":-83.1591411410959,"temperature":7.9,"relativehumidity_2m":95,"windspeed_10m":7.8},{"datetime":"2024-10-11T08:00:00.000+02:00","dcPower":0.302792297198549,"power":0.24223383775883922,"sunTilt":5.683710585945252,"sunAzimuth":-71.44774052199065,"temperature":7.5,"relativehumidity_2m":96,"windspeed_10m":6.9},{"datetime":"2024-10-11T09:00:00.000+02:00","dcPower":37.91183172590186,"power":30.32946538072149,"sunTilt":14.260824082236146,"sunAzimuth":-59.0789120948653,"temperature":7.8,"relativehumidity_2m":93,"windspeed_10m":8.6},{"datetime":"2024-10-11T10:00:00.000+02:00","dcPower":135.3300924208186,"power":108.26407393665488,"sunTilt":21.734940400779433,"sunAzimuth":-45.525661029390804,"temperature":8.1,"relativehumidity_2m":89,"windspeed_10m":8.2},{"datetime":"2024-10-11T11:00:00.000+02:00","dcPower":722.9159926052826,"power":578.3327940842261,"sunTilt":27.54875518315372,"sunAzimuth":-30.429767533564867,"temperature":9,"relativehumidity_2m":84,"windspeed_10m":7.6},{"datetime":"2024-10-11T12:00:00.000+02:00","dcPower":761.1134305872083,"power":608.8907444697667,"sunTilt":31.105090976135774,"sunAzimuth":-13.830431372703888,"temperature":9.8,"relativehumidity_2m":79,"windspeed_10m":7.4},{"datetime":"2024-10-11T13:00:00.000+02:00","dcPower":793.9765474576169,"power":635.1812379660936,"sunTilt":31.9332985100557,"sunAzimuth":3.6233914143032355,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":6.5},{"datetime":"2024-10-11T14:00:00.000+02:00","dcPower":275.3528143308291,"power":220.28225146466332,"sunTilt":29.908639212205873,"sunAzimuth":20.83241114244682,"temperature":10.8,"relativehumidity_2m":71,"windspeed_10m":5.1},{"datetime":"2024-10-11T15:00:00.000+02:00","dcPower":242.57658474188835,"power":194.0612677935107,"sunTilt":25.3232617284627,"sunAzimuth":36.83295334168066,"temperature":11.1,"relativehumidity_2m":69,"windspeed_10m":4.3},{"datetime":"2024-10-11T16:00:00.000+02:00","dcPower":198.5838760223071,"power":158.86710081784568,"sunTilt":18.727781114603793,"sunAzimuth":51.243806920166435,"temperature":11.2,"relativehumidity_2m":68,"windspeed_10m":4},{"datetime":"2024-10-11T17:00:00.000+02:00","dcPower":167.6766368130286,"power":134.14130945042288,"sunTilt":10.714725694488843,"sunAzimuth":64.22660182758023,"temperature":10.9,"relativehumidity_2m":71,"windspeed_10m":4.1},{"datetime":"2024-10-11T18:00:00.000+02:00","dcPower":89.39926864717155,"power":71.51941491773725,"sunTilt":1.803206213409536,"sunAzimuth":76.23115674859116,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":0.5},{"datetime":"2024-10-11T19:00:00.000+02:00","dcPower":18.16668170354839,"power":14.533345362838714,"sunTilt":-7.571835214892863,"sunAzimuth":87.81749031617603,"temperature":9,"relativehumidity_2m":79,"windspeed_10m":1.9},{"datetime":"2024-10-11T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.0181265759899,"sunAzimuth":99.60341073267222,"temperature":7.9,"relativehumidity_2m":83,"windspeed_10m":2.4},{"datetime":"2024-10-11T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.122656701936815,"sunAzimuth":112.28457164140065,"temperature":7,"relativehumidity_2m":85,"windspeed_10m":2.9},{"datetime":"2024-10-11T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.37321335134456,"sunAzimuth":126.65558045028693,"temperature":6.5,"relativehumidity_2m":86,"windspeed_10m":3},{"datetime":"2024-10-11T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.07743516418665,"sunAzimuth":143.5011643499793,"temperature":6,"relativehumidity_2m":88,"windspeed_10m":3.3},{"datetime":"2024-10-12T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.35684572865986,"sunAzimuth":163.11851362268473,"temperature":5.4,"relativehumidity_2m":89,"windspeed_10m":4.8},{"datetime":"2024-10-12T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.397705346785955,"sunAzimuth":-175.49580600360676,"temperature":5.1,"relativehumidity_2m":90,"windspeed_10m":4.7},{"datetime":"2024-10-12T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.961535734103165,"sunAzimuth":-154.60751324634,"temperature":4.9,"relativehumidity_2m":90,"windspeed_10m":5.1},{"datetime":"2024-10-12T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.58320673599567,"sunAzimuth":-136.09226665392447,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.1},{"datetime":"2024-10-12T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.157657336040163,"sunAzimuth":-120.33499159290702,"temperature":4.9,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.499060815350038,"sunAzimuth":-106.7481901878237,"temperature":4.8,"relativehumidity_2m":91,"windspeed_10m":5},{"datetime":"2024-10-12T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.218886384957228,"sunAzimuth":-94.51232726794028,"temperature":4.6,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-3.77960910849284,"sunAzimuth":-82.87218040699524,"temperature":4.5,"relativehumidity_2m":91,"windspeed_10m":5.4},{"datetime":"2024-10-12T08:00:00.000+02:00","dcPower":0.3056636135610723,"power":0.24453089084885785,"sunTilt":5.421444806545091,"sunAzimuth":-71.16958809655219,"temperature":4.7,"relativehumidity_2m":92,"windspeed_10m":6.6},{"datetime":"2024-10-12T09:00:00.000+02:00","dcPower":56.5836564374369,"power":45.26692514994952,"sunTilt":13.979744388367903,"sunAzimuth":-58.8132832097472,"temperature":5.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-12T10:00:00.000+02:00","dcPower":159.33697905638047,"power":127.46958324510439,"sunTilt":21.42814200059926,"sunAzimuth":-45.28238230412684,"temperature":7.3,"relativehumidity_2m":87,"windspeed_10m":9.9},{"datetime":"2024-10-12T11:00:00.000+02:00","dcPower":309.3330857887113,"power":247.46646863096905,"sunTilt":27.21286115693797,"sunAzimuth":-30.227011858140273,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":10.4},{"datetime":"2024-10-12T12:00:00.000+02:00","dcPower":698.9561093595228,"power":559.1648874876182,"sunTilt":30.743014004100917,"sunAzimuth":-13.692125978099353,"temperature":10.1,"relativehumidity_2m":82,"windspeed_10m":12.3},{"datetime":"2024-10-12T13:00:00.000+02:00","dcPower":592.5273272321114,"power":474.0218617856891,"sunTilt":31.555283363449327,"sunAzimuth":3.6787683789403496,"temperature":10.6,"relativehumidity_2m":81,"windspeed_10m":12.4},{"datetime":"2024-10-12T14:00:00.000+02:00","dcPower":358.1654792118635,"power":286.5323833694908,"sunTilt":29.52856227644022,"sunAzimuth":20.80440880713897,"temperature":11.2,"relativehumidity_2m":80,"windspeed_10m":11.3},{"datetime":"2024-10-12T15:00:00.000+02:00","dcPower":315.5891511722822,"power":252.47132093782577,"sunTilt":24.95225483898691,"sunAzimuth":36.7383136221432,"temperature":11.7,"relativehumidity_2m":80,"windspeed_10m":9.7},{"datetime":"2024-10-12T16:00:00.000+02:00","dcPower":212.23365097385258,"power":169.78692077908207,"sunTilt":18.37082552430316,"sunAzimuth":51.10424752804786,"temperature":11.5,"relativehumidity_2m":82,"windspeed_10m":8.4},{"datetime":"2024-10-12T17:00:00.000+02:00","dcPower":139.49419269628333,"power":111.59535415702668,"sunTilt":10.371392509810583,"sunAzimuth":64.05951991866706,"temperature":11.1,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-12T18:00:00.000+02:00","dcPower":50.4747106285505,"power":40.3797685028404,"sunTilt":1.4697694157713943,"sunAzimuth":76.0477365277625,"temperature":10.5,"relativehumidity_2m":87,"windspeed_10m":8.4},{"datetime":"2024-10-12T19:00:00.000+02:00","dcPower":7.807218137175268,"power":6.245774509740215,"sunTilt":-7.900822080898683,"sunAzimuth":87.62468730812897,"temperature":9.8,"relativehumidity_2m":90,"windspeed_10m":8.5},{"datetime":"2024-10-12T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.349008833561726,"sunAzimuth":99.4070709783674,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":8.6},{"datetime":"2024-10-12T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.462114969564762,"sunAzimuth":112.09341375841275,"temperature":9.6,"relativehumidity_2m":92,"windspeed_10m":9.2},{"datetime":"2024-10-12T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-34.7271125456906,"sunAzimuth":126.48693033734844,"temperature":9.7,"relativehumidity_2m":92,"windspeed_10m":8},{"datetime":"2024-10-12T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.447828525061254,"sunAzimuth":143.38762883737587,"temperature":9.4,"relativehumidity_2m":94,"windspeed_10m":7.3},{"datetime":"2024-10-13T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.73675190204195,"sunAzimuth":163.1056210331007,"temperature":9.3,"relativehumidity_2m":94,"windspeed_10m":6.5},{"datetime":"2024-10-13T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.76906905918878,"sunAzimuth":-175.37909348313417,"temperature":9.1,"relativehumidity_2m":94,"windspeed_10m":7.4},{"datetime":"2024-10-13T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.30526573902631,"sunAzimuth":-154.3822860597556,"temperature":9.5,"relativehumidity_2m":92,"windspeed_10m":10},{"datetime":"2024-10-13T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-38.89221927072221,"sunAzimuth":-135.80926949668307,"temperature":9.6,"relativehumidity_2m":90,"windspeed_10m":16.3},{"datetime":"2024-10-13T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.436918053719957,"sunAzimuth":-120.03396199414307,"temperature":8.8,"relativehumidity_2m":87,"windspeed_10m":18},{"datetime":"2024-10-13T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-22.75867399010402,"sunAzimuth":-106.44816955814555,"temperature":8.4,"relativehumidity_2m":87,"windspeed_10m":15.5},{"datetime":"2024-10-13T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.469627601285604,"sunAzimuth":-94.21955537307969,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":17},{"datetime":"2024-10-13T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.0315450153141486,"sunAzimuth":-82.58782406375519,"temperature":7.2,"relativehumidity_2m":82,"windspeed_10m":16.2},{"datetime":"2024-10-13T08:00:00.000+02:00","dcPower":0.30361267330212705,"power":0.24289013864170164,"sunTilt":5.159076534957485,"sunAzimuth":-70.89445910846419,"temperature":6.7,"relativehumidity_2m":84,"windspeed_10m":16.8},{"datetime":"2024-10-13T09:00:00.000+02:00","dcPower":41.66339591027437,"power":33.33071672821949,"sunTilt":13.698600583367673,"sunAzimuth":-58.55115047580995,"temperature":6.9,"relativehumidity_2m":86,"windspeed_10m":15.5},{"datetime":"2024-10-13T10:00:00.000+02:00","dcPower":121.29615381863722,"power":97.03692305490978,"sunTilt":21.12151408595382,"sunAzimuth":-45.043053146408546,"temperature":7.5,"relativehumidity_2m":83,"windspeed_10m":22.7},{"datetime":"2024-10-13T11:00:00.000+02:00","dcPower":247.51263219299824,"power":198.0101057543986,"sunTilt":26.877585132999467,"sunAzimuth":-30.028428502780528,"temperature":8.1,"relativehumidity_2m":83,"windspeed_10m":19.5},{"datetime":"2024-10-13T12:00:00.000+02:00","dcPower":338.6846185017703,"power":270.9476948014162,"sunTilt":30.382147107830978,"sunAzimuth":-13.557685005032592,"temperature":8.5,"relativehumidity_2m":82,"windspeed_10m":18.9},{"datetime":"2024-10-13T13:00:00.000+02:00","dcPower":605.5390983533022,"power":484.43127868264173,"sunTilt":31.17904416002473,"sunAzimuth":3.731196531846679,"temperature":9.4,"relativehumidity_2m":79,"windspeed_10m":19.1},{"datetime":"2024-10-13T14:00:00.000+02:00","dcPower":288.2119804513333,"power":230.56958436106666,"sunTilt":29.150649828823855,"sunAzimuth":20.774587486422366,"temperature":10.4,"relativehumidity_2m":75,"windspeed_10m":17.7},{"datetime":"2024-10-13T15:00:00.000+02:00","dcPower":265.18083697354695,"power":212.14466957883758,"sunTilt":24.583621654336596,"sunAzimuth":36.642703763191776,"temperature":10.3,"relativehumidity_2m":74,"windspeed_10m":17.6},{"datetime":"2024-10-13T16:00:00.000+02:00","dcPower":219.66624712152574,"power":175.7329976972206,"sunTilt":18.016365562424813,"sunAzimuth":50.96413031013026,"temperature":10.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-13T17:00:00.000+02:00","dcPower":163.24002688558107,"power":130.59202150846485,"sunTilt":10.030663139779685,"sunAzimuth":63.89195411454739,"temperature":9.9,"relativehumidity_2m":79,"windspeed_10m":13.7},{"datetime":"2024-10-13T18:00:00.000+02:00","dcPower":67.85462774565694,"power":54.28370219652555,"sunTilt":1.1390561362362333,"sunAzimuth":75.8636886240363,"temperature":9.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-13T19:00:00.000+02:00","dcPower":9.115748605658235,"power":7.292598884526588,"sunTilt":-8.226960953264081,"sunAzimuth":87.43095436456028,"temperature":8.3,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-13T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-17.676940156857277,"sunAzimuth":99.2093472034457,"temperature":8.1,"relativehumidity_2m":87,"windspeed_10m":6.4},{"datetime":"2024-10-13T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-26.79858696849319,"sunAzimuth":111.90025906076745,"temperature":7.6,"relativehumidity_2m":90,"windspeed_10m":6.8},{"datetime":"2024-10-13T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.0781256664598,"sunAzimuth":126.3155777234557,"temperature":7.1,"relativehumidity_2m":90,"windspeed_10m":5},{"datetime":"2024-10-13T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-41.815632684929426,"sunAzimuth":143.2709019404984,"temperature":6.5,"relativehumidity_2m":91,"windspeed_10m":2.6},{"datetime":"2024-10-14T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.11451301786215,"sunAzimuth":163.08983536166957,"temperature":6.3,"relativehumidity_2m":89,"windspeed_10m":2.4},{"datetime":"2024-10-14T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.138671848584934,"sunAzimuth":-175.2642016873541,"temperature":5.8,"relativehumidity_2m":89,"windspeed_10m":1.5},{"datetime":"2024-10-14T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.647478099904184,"sunAzimuth":-154.15814092455744,"temperature":5.3,"relativehumidity_2m":90,"windspeed_10m":4},{"datetime":"2024-10-14T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.1999672120048,"sunAzimuth":-135.52742842855076,"temperature":4.5,"relativehumidity_2m":93,"windspeed_10m":1.5},{"datetime":"2024-10-14T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.715244466780028,"sunAzimuth":-119.73450334119632,"temperature":4,"relativehumidity_2m":94,"windspeed_10m":4.5},{"datetime":"2024-10-14T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.01770469074795,"sunAzimuth":-106.15013533780943,"temperature":3.8,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.720095220219516,"sunAzimuth":-93.92913753819082,"temperature":4.2,"relativehumidity_2m":94,"windspeed_10m":5.8},{"datetime":"2024-10-14T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.283430715645694,"sunAzimuth":-82.30618839226909,"temperature":4.7,"relativehumidity_2m":94,"windspeed_10m":4.7},{"datetime":"2024-10-14T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.896654311121907,"sunAzimuth":-70.62246187023919,"temperature":5.1,"relativehumidity_2m":93,"windspeed_10m":4.3},{"datetime":"2024-10-14T09:00:00.000+02:00","dcPower":37.995807256660015,"power":30.396645805328014,"sunTilt":13.417455844515738,"sunAzimuth":-58.29261188464403,"temperature":5.6,"relativehumidity_2m":92,"windspeed_10m":3.8},{"datetime":"2024-10-14T10:00:00.000+02:00","dcPower":116.02924326198367,"power":92.82339460958694,"sunTilt":20.815136015137117,"sunAzimuth":-44.807755986987566,"temperature":6.7,"relativehumidity_2m":87,"windspeed_10m":6.2},{"datetime":"2024-10-14T11:00:00.000+02:00","dcPower":180.41800188431606,"power":144.33440150745284,"sunTilt":26.543021174019792,"sunAzimuth":-29.83407952105961,"temperature":7.5,"relativehumidity_2m":85,"windspeed_10m":10.1},{"datetime":"2024-10-14T12:00:00.000+02:00","dcPower":155.60694186631315,"power":124.48555349305053,"sunTilt":30.022594925866507,"sunAzimuth":-13.427153142476177,"temperature":7.8,"relativehumidity_2m":87,"windspeed_10m":7.4},{"datetime":"2024-10-14T13:00:00.000+02:00","dcPower":216.81729696297057,"power":173.45383757037646,"sunTilt":30.804692641790414,"sunAzimuth":3.780636799023051,"temperature":8.7,"relativehumidity_2m":84,"windspeed_10m":9.4},{"datetime":"2024-10-14T14:00:00.000+02:00","dcPower":218.6247681364855,"power":174.89981450918842,"sunTilt":28.775019978770835,"sunAzimuth":20.742907009369926,"temperature":8.8,"relativehumidity_2m":83,"windspeed_10m":7.7},{"datetime":"2024-10-14T15:00:00.000+02:00","dcPower":200.945630471392,"power":160.75650437711363,"sunTilt":24.217486258022436,"sunAzimuth":36.546087194880954,"temperature":8.8,"relativehumidity_2m":85,"windspeed_10m":6.5},{"datetime":"2024-10-14T16:00:00.000+02:00","dcPower":168.57441974497019,"power":134.85953579597614,"sunTilt":17.664529576020236,"sunAzimuth":50.82342857831685,"temperature":8.7,"relativehumidity_2m":88,"windspeed_10m":5.2},{"datetime":"2024-10-14T17:00:00.000+02:00","dcPower":116.8428850480738,"power":93.47430803845904,"sunTilt":9.692668263612882,"sunAzimuth":63.7238891712367,"temperature":8.4,"relativehumidity_2m":90,"windspeed_10m":3.6},{"datetime":"2024-10-14T18:00:00.000+02:00","dcPower":54.17222871388804,"power":43.337782971110435,"sunTilt":0.8111983279457368,"sunAzimuth":75.67900710147994,"temperature":8.2,"relativehumidity_2m":91,"windspeed_10m":1.8},{"datetime":"2024-10-14T19:00:00.000+02:00","dcPower":9.066031160044716,"power":7.252824928035773,"sunTilt":-8.5501186243532,"sunAzimuth":87.23629074336492,"temperature":7.9,"relativehumidity_2m":93,"windspeed_10m":0.4},{"datetime":"2024-10-14T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.00178541748869,"sunAzimuth":99.01023762777291,"temperature":7.4,"relativehumidity_2m":94,"windspeed_10m":1.1},{"datetime":"2024-10-14T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.131935065748255,"sunAzimuth":111.70509440468732,"temperature":6.7,"relativehumidity_2m":96,"windspeed_10m":1.1},{"datetime":"2024-10-14T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.426113802819536,"sunAzimuth":126.14148133580082,"temperature":5.8,"relativehumidity_2m":98,"windspeed_10m":0.4},{"datetime":"2024-10-14T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.180713378529184,"sunAzimuth":143.15089515757862,"temperature":5,"relativehumidity_2m":100,"windspeed_10m":0.7},{"datetime":"2024-10-15T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.49001057641694,"sunAzimuth":163.07102046565072,"temperature":4.3,"relativehumidity_2m":100,"windspeed_10m":1.4},{"datetime":"2024-10-15T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.506419588733614,"sunAzimuth":-175.15128768425117,"temperature":3.8,"relativehumidity_2m":100,"windspeed_10m":1.9},{"datetime":"2024-10-15T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-44.988103658491234,"sunAzimuth":-153.9352385869432,"temperature":3.2,"relativehumidity_2m":100,"windspeed_10m":2.6},{"datetime":"2024-10-15T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.50640211284843,"sunAzimuth":-135.24690137263536,"temperature":2.4,"relativehumidity_2m":100,"windspeed_10m":2.9},{"datetime":"2024-10-15T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-31.992601433814595,"sunAzimuth":-119.43676351875516,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.2},{"datetime":"2024-10-15T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.276123004228086,"sunAzimuth":-105.85422295783262,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-13.970257710966642,"sunAzimuth":-93.64119828341282,"temperature":1.2,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.5352276078301,"sunAzimuth":-82.02738904900566,"temperature":1.6,"relativehumidity_2m":100,"windspeed_10m":3.6},{"datetime":"2024-10-15T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.63422819396966,"sunAzimuth":-70.35370359566531,"temperature":2.3,"relativehumidity_2m":100,"windspeed_10m":4},{"datetime":"2024-10-15T09:00:00.000+02:00","dcPower":47.164824265677275,"power":37.73185941254182,"sunTilt":13.136374944499462,"sunAzimuth":-58.037763824819606,"temperature":3.4,"relativehumidity_2m":98,"windspeed_10m":5},{"datetime":"2024-10-15T10:00:00.000+02:00","dcPower":133.5723417231025,"power":106.85787337848201,"sunTilt":20.50908861823578,"sunAzimuth":-44.57657122307415,"temperature":4.8,"relativehumidity_2m":94,"windspeed_10m":6.2},{"datetime":"2024-10-15T11:00:00.000+02:00","dcPower":563.2310951818382,"power":450.5848761454706,"sunTilt":26.20926450679345,"sunAzimuth":-29.644024765315514,"temperature":6.3,"relativehumidity_2m":91,"windspeed_10m":7.8},{"datetime":"2024-10-15T12:00:00.000+02:00","dcPower":781.5834949272705,"power":625.2667959418164,"sunTilt":29.66446288060627,"sunAzimuth":-13.300572957692536,"temperature":8,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-15T13:00:00.000+02:00","dcPower":1002.397172852398,"power":801.9177382819184,"sunTilt":30.43234097439692,"sunAzimuth":3.8270522061418095,"temperature":9.7,"relativehumidity_2m":84,"windspeed_10m":10.9},{"datetime":"2024-10-15T14:00:00.000+02:00","dcPower":241.9896564816097,"power":193.59172518528777,"sunTilt":28.401790922595836,"sunAzimuth":20.709329389098386,"temperature":10.8,"relativehumidity_2m":82,"windspeed_10m":11.9},{"datetime":"2024-10-15T15:00:00.000+02:00","dcPower":222.3817911463646,"power":177.9054329170917,"sunTilt":23.853972526435296,"sunAzimuth":36.448429487078144,"temperature":11.2,"relativehumidity_2m":81,"windspeed_10m":11.9},{"datetime":"2024-10-15T16:00:00.000+02:00","dcPower":188.86845125050397,"power":151.09476100040317,"sunTilt":17.315445485866938,"sunAzimuth":50.682117672382816,"temperature":11,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-15T17:00:00.000+02:00","dcPower":139.12806007081542,"power":111.30244805665234,"sunTilt":9.35753798215806,"sunAzimuth":63.55531186506398,"temperature":10.6,"relativehumidity_2m":82,"windspeed_10m":11.1},{"datetime":"2024-10-15T18:00:00.000+02:00","dcPower":74.93247651101706,"power":59.94598120881365,"sunTilt":0.48632726290863104,"sunAzimuth":75.49368820866877,"temperature":9.7,"relativehumidity_2m":85,"windspeed_10m":12.1},{"datetime":"2024-10-15T19:00:00.000+02:00","dcPower":15.878086979814217,"power":12.702469583851375,"sunTilt":-8.870162619560801,"sunAzimuth":87.04069822248259,"temperature":8.5,"relativehumidity_2m":88,"windspeed_10m":13.6},{"datetime":"2024-10-15T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.323410184499085,"sunAzimuth":98.80974345652376,"temperature":7.6,"relativehumidity_2m":91,"windspeed_10m":14.3},{"datetime":"2024-10-15T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.462022150796383,"sunAzimuth":111.50791010170794,"temperature":7.2,"relativehumidity_2m":91,"windspeed_10m":13.9},{"datetime":"2024-10-15T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-35.770938188604354,"sunAzimuth":125.96460338334036,"temperature":7,"relativehumidity_2m":91,"windspeed_10m":12.9},{"datetime":"2024-10-15T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.54293600102064,"sunAzimuth":143.02752222319543,"temperature":6.8,"relativehumidity_2m":89,"windspeed_10m":12.7},{"datetime":"2024-10-16T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-46.86312551477473,"sunAzimuth":163.04903976266138,"temperature":6.7,"relativehumidity_2m":86,"windspeed_10m":13.8},{"datetime":"2024-10-16T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.87221785059982,"sunAzimuth":-175.04051110279474,"temperature":6.8,"relativehumidity_2m":83,"windspeed_10m":15.6},{"datetime":"2024-10-16T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.32707330350441,"sunAzimuth":-153.71374265034802,"temperature":6.8,"relativehumidity_2m":80,"windspeed_10m":16.5},{"datetime":"2024-10-16T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-39.81147561550361,"sunAzimuth":-134.96784827714956,"temperature":7,"relativehumidity_2m":78,"windspeed_10m":16.1},{"datetime":"2024-10-16T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.26895363243685,"sunAzimuth":-119.1408914506387,"temperature":7.3,"relativehumidity_2m":77,"windspeed_10m":15.1},{"datetime":"2024-10-16T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.53389843309148,"sunAzimuth":-105.56056815727264,"temperature":7.4,"relativehumidity_2m":75,"windspeed_10m":14.3},{"datetime":"2024-10-16T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.220082554609817,"sunAzimuth":-93.355861910257,"temperature":7.4,"relativehumidity_2m":73,"windspeed_10m":14.3},{"datetime":"2024-10-16T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-4.786895768698711,"sunAzimuth":-81.75154101774274,"temperature":7.3,"relativehumidity_2m":71,"windspeed_10m":14.7},{"datetime":"2024-10-16T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.371849783596975,"sunAzimuth":-70.08829035405888,"temperature":7.2,"relativehumidity_2m":70,"windspeed_10m":14.8},{"datetime":"2024-10-16T09:00:00.000+02:00","dcPower":35.08288202776703,"power":28.066305622213623,"sunTilt":12.855424254104108,"sunAzimuth":-57.78670104663053,"temperature":9.8,"relativehumidity_2m":72,"windspeed_10m":17},{"datetime":"2024-10-16T10:00:00.000+02:00","dcPower":101.57338329220217,"power":81.25870663376173,"sunTilt":20.203454185088123,"sunAzimuth":-44.34957719582011,"temperature":10.2,"relativehumidity_2m":75,"windspeed_10m":16.5},{"datetime":"2024-10-16T11:00:00.000+02:00","dcPower":349.42380002507434,"power":279.5390400200595,"sunTilt":25.87641150088954,"sunAzimuth":-29.45832187587005,"temperature":10.7,"relativehumidity_2m":77,"windspeed_10m":16.2},{"datetime":"2024-10-16T12:00:00.000+02:00","dcPower":494.1678347786919,"power":395.3342678229535,"sunTilt":29.30785715204484,"sunAzimuth":-13.177984892004138,"temperature":11.6,"relativehumidity_2m":77,"windspeed_10m":15.7},{"datetime":"2024-10-16T13:00:00.000+02:00","dcPower":639.0240315979279,"power":511.2192252783423,"sunTilt":30.062101719417093,"sunAzimuth":3.870407893111919,"temperature":12.5,"relativehumidity_2m":75,"windspeed_10m":15.3},{"datetime":"2024-10-16T14:00:00.000+02:00","dcPower":277.7856212217981,"power":222.2284969774385,"sunTilt":28.03108091352568,"sunAzimuth":20.673818848950724,"temperature":13.2,"relativehumidity_2m":75,"windspeed_10m":14.8},{"datetime":"2024-10-16T15:00:00.000+02:00","dcPower":252.83380974430276,"power":202.2670477954422,"sunTilt":23.49320409080551,"sunAzimuth":36.34969840216598,"temperature":13.5,"relativehumidity_2m":77,"windspeed_10m":14.3},{"datetime":"2024-10-16T16:00:00.000+02:00","dcPower":198.08712950041433,"power":158.46970360033148,"sunTilt":16.969240740198778,"sunAzimuth":50.54017502787197,"temperature":13.5,"relativehumidity_2m":80,"windspeed_10m":13.7},{"datetime":"2024-10-16T17:00:00.000+02:00","dcPower":134.9942461001432,"power":107.99539688011455,"sunTilt":9.025401762418719,"sunAzimuth":63.386211071389155,"temperature":13.3,"relativehumidity_2m":82,"windspeed_10m":13.2},{"datetime":"2024-10-16T18:00:00.000+02:00","dcPower":66.70716275854775,"power":53.36573020683821,"sunTilt":0.16457346744538273,"sunAzimuth":75.30773046735078,"temperature":13,"relativehumidity_2m":82,"windspeed_10m":13},{"datetime":"2024-10-16T19:00:00.000+02:00","dcPower":13.56117696026841,"power":10.848941568214729,"sunTilt":-9.186961271997145,"sunAzimuth":86.84418120594421,"temperature":12.6,"relativehumidity_2m":81,"windspeed_10m":12.7},{"datetime":"2024-10-16T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.64168080965683,"sunAzimuth":98.60786901950698,"temperature":12.2,"relativehumidity_2m":80,"windspeed_10m":12.2},{"datetime":"2024-10-16T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-27.788711717705176,"sunAzimuth":111.30870010109864,"temperature":12,"relativehumidity_2m":80,"windspeed_10m":11.8},{"datetime":"2024-10-16T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.112460277357016,"sunAzimuth":125.78490981561183,"temperature":11.8,"relativehumidity_2m":81,"windspeed_10m":11},{"datetime":"2024-10-16T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-42.9021656490531,"sunAzimuth":142.90069940402603,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":10.2},{"datetime":"2024-10-17T00:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-47.23373820466217,"sunAzimuth":163.02375644913673,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":9.4},{"datetime":"2024-10-17T01:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-48.2359718752176,"sunAzimuth":-174.9320340577077,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.6},{"datetime":"2024-10-17T02:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-45.66431793404074,"sunAzimuth":-153.4938195445769,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":8.2},{"datetime":"2024-10-17T03:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-40.115139405653565,"sunAzimuth":-134.69043107615582,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.9},{"datetime":"2024-10-17T04:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-32.54426550294801,"sunAzimuth":-118.84703704658551,"temperature":11.8,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T05:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-23.79099983796868,"sunAzimuth":-105.2693069294498,"temperature":11.7,"relativehumidity_2m":82,"windspeed_10m":7.6},{"datetime":"2024-10-17T06:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-14.469536195186127,"sunAzimuth":-93.07325245162968,"temperature":11.6,"relativehumidity_2m":82,"windspeed_10m":7.4},{"datetime":"2024-10-17T07:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-5.038393916921545,"sunAzimuth":-81.47875856041325,"temperature":11.4,"relativehumidity_2m":83,"windspeed_10m":7.3},{"datetime":"2024-10-17T08:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":4.109572239802723,"sunAzimuth":-69.8263270263417,"temperature":11.5,"relativehumidity_2m":83,"windspeed_10m":7.4},{"datetime":"2024-10-17T09:00:00.000+02:00","dcPower":31.67872965996608,"power":25.342983727972864,"sunTilt":12.574671747325786,"sunAzimuth":-57.53951661964237,"temperature":11.9,"relativehumidity_2m":83,"windspeed_10m":8},{"datetime":"2024-10-17T10:00:00.000+02:00","dcPower":94.35489246212374,"power":75.483913969699,"sunTilt":19.89831645004539,"sunAzimuth":-44.1268501705138,"temperature":12.5,"relativehumidity_2m":82,"windspeed_10m":9.7},{"datetime":"2024-10-17T11:00:00.000+02:00","dcPower":271.1188829478831,"power":216.8951063583065,"sunTilt":25.54455964459646,"sunAzimuth":-29.277026273264024,"temperature":13.1,"relativehumidity_2m":82,"windspeed_10m":10.8},{"datetime":"2024-10-17T12:00:00.000+02:00","dcPower":309.5618220679901,"power":247.64945765439208,"sunTilt":28.952884649607554,"sunAzimuth":-13.059427258390235,"temperature":13.5,"relativehumidity_2m":83,"windspeed_10m":10.7},{"datetime":"2024-10-17T13:00:00.000+02:00","dcPower":219.1359929657014,"power":175.30879437256112,"sunTilt":29.694087805098004,"sunAzimuth":3.9106711264578404,"temperature":13.8,"relativehumidity_2m":85,"windspeed_10m":10.8},{"datetime":"2024-10-17T14:00:00.000+02:00","dcPower":228.55502409292535,"power":182.8440192743403,"sunTilt":27.663008228484166,"sunAzimuth":20.63634185579284,"temperature":14.2,"relativehumidity_2m":86,"windspeed_10m":10.5},{"datetime":"2024-10-17T15:00:00.000+02:00","dcPower":218.107673955093,"power":174.48613916407442,"sunTilt":23.13530429931852,"sunAzimuth":36.24986394147597,"temperature":14.6,"relativehumidity_2m":87,"windspeed_10m":9.8},{"datetime":"2024-10-17T16:00:00.000+02:00","dcPower":187.6762781627061,"power":150.1410225301649,"sunTilt":16.626042266391202,"sunAzimuth":50.39758024284303,"temperature":14.9,"relativehumidity_2m":88,"windspeed_10m":9.2},{"datetime":"2024-10-17T17:00:00.000+02:00","dcPower":135.3327891382026,"power":108.26623131056209,"sunTilt":8.696388379725821,"sunAzimuth":63.21657784217465,"temperature":15.1,"relativehumidity_2m":89,"windspeed_10m":8.7},{"datetime":"2024-10-17T18:00:00.000+02:00","dcPower":66.58720027019501,"power":53.269760216156016,"sunTilt":-0.15393334821361332,"sunAzimuth":75.12113476396313,"temperature":14.9,"relativehumidity_2m":90,"windspeed_10m":7.9},{"datetime":"2024-10-17T19:00:00.000+02:00","dcPower":11.87138687150887,"power":9.497109497207097,"sunTilt":-9.500383797971471,"sunAzimuth":86.64674682587622,"temperature":14.6,"relativehumidity_2m":92,"windspeed_10m":6.7},{"datetime":"2024-10-17T20:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-18.956464510241965,"sunAzimuth":98.40462190157557,"temperature":14.3,"relativehumidity_2m":93,"windspeed_10m":5.9},{"datetime":"2024-10-17T21:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-28.11186795253563,"sunAzimuth":111.10746217430679,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":5.4},{"datetime":"2024-10-17T22:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-36.45054181856139,"sunAzimuth":125.60237057929713,"temperature":14.2,"relativehumidity_2m":94,"windspeed_10m":4.6},{"datetime":"2024-10-17T23:00:00.000+02:00","dcPower":0,"power":0,"sunTilt":-43.2582671659911,"sunAzimuth":142.77034580724174,"temperature":14.2,"relativehumidity_2m":95,"windspeed_10m":4.4}]]} diff --git a/tests/testdata/pv_forecast_result_1.txt b/tests/testdata/pv_forecast_result_1.txt new file mode 100644 index 00000000..65a33a33 --- /dev/null +++ b/tests/testdata/pv_forecast_result_1.txt @@ -0,0 +1,288 @@ +Zeit: 2024-10-06 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 08:00:00, DC: 29.083233405355106, AC: 23.266586724284085, Messwert: None, AC GET: 23.266586724284085 +Zeit: 2024-10-06 09:00:00, DC: 603.9155913762734, AC: 483.1324731010187, Messwert: None, AC GET: 483.1324731010187 +Zeit: 2024-10-06 10:00:00, DC: 1202.5733064798087, AC: 962.0586451838469, Messwert: None, AC GET: 962.0586451838469 +Zeit: 2024-10-06 11:00:00, DC: 3680.674142237336, AC: 2944.539313789869, Messwert: None, AC GET: 2944.539313789869 +Zeit: 2024-10-06 12:00:00, DC: 4757.349301221422, AC: 3805.8794409771376, Messwert: None, AC GET: 3805.8794409771376 +Zeit: 2024-10-06 13:00:00, DC: 4976.074631762431, AC: 3980.8597054099446, Messwert: None, AC GET: 3980.8597054099446 +Zeit: 2024-10-06 14:00:00, DC: 4661.218846907677, AC: 3728.975077526142, Messwert: None, AC GET: 3728.975077526142 +Zeit: 2024-10-06 15:00:00, DC: 3946.8020263136905, AC: 3157.4416210509526, Messwert: None, AC GET: 3157.4416210509526 +Zeit: 2024-10-06 16:00:00, DC: 2243.0039568971824, AC: 1794.4031655177462, Messwert: None, AC GET: 1794.4031655177462 +Zeit: 2024-10-06 17:00:00, DC: 2001.7063462507354, AC: 1601.3650770005884, Messwert: None, AC GET: 1601.3650770005884 +Zeit: 2024-10-06 18:00:00, DC: 1064.8644840801587, AC: 851.891587264127, Messwert: None, AC GET: 851.891587264127 +Zeit: 2024-10-06 19:00:00, DC: 213.62586203307436, AC: 170.9006896264595, Messwert: None, AC GET: 170.9006896264595 +Zeit: 2024-10-06 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-06 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 08:00:00, DC: 12.879067406412917, AC: 10.303253925130335, Messwert: None, AC GET: 10.303253925130335 +Zeit: 2024-10-07 09:00:00, DC: 262.9677324587079, AC: 210.37418596696637, Messwert: None, AC GET: 210.37418596696637 +Zeit: 2024-10-07 10:00:00, DC: 1055.8307755341434, AC: 844.6646204273148, Messwert: None, AC GET: 844.6646204273148 +Zeit: 2024-10-07 11:00:00, DC: 1047.9202921493936, AC: 838.3362337195149, Messwert: None, AC GET: 838.3362337195149 +Zeit: 2024-10-07 12:00:00, DC: 1974.588548831151, AC: 1579.6708390649208, Messwert: None, AC GET: 1579.6708390649208 +Zeit: 2024-10-07 13:00:00, DC: 3705.4090777906226, AC: 2964.327262232498, Messwert: None, AC GET: 2964.327262232498 +Zeit: 2024-10-07 14:00:00, DC: 4302.5339613230035, AC: 3442.0271690584027, Messwert: None, AC GET: 3442.0271690584027 +Zeit: 2024-10-07 15:00:00, DC: 3329.4322858356677, AC: 2663.5458286685343, Messwert: None, AC GET: 2663.5458286685343 +Zeit: 2024-10-07 16:00:00, DC: 2230.6158883385765, AC: 1784.4927106708612, Messwert: None, AC GET: 1784.4927106708612 +Zeit: 2024-10-07 17:00:00, DC: 1963.7588469904522, AC: 1571.0070775923618, Messwert: None, AC GET: 1571.0070775923618 +Zeit: 2024-10-07 18:00:00, DC: 893.4702395519014, AC: 714.7761916415211, Messwert: None, AC GET: 714.7761916415211 +Zeit: 2024-10-07 19:00:00, DC: 196.79652262455014, AC: 157.43721809964012, Messwert: None, AC GET: 157.43721809964012 +Zeit: 2024-10-07 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-07 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 08:00:00, DC: 10.196920114210368, AC: 8.157536091368295, Messwert: None, AC GET: 8.157536091368295 +Zeit: 2024-10-08 09:00:00, DC: 533.3040032153134, AC: 426.64320257225074, Messwert: None, AC GET: 426.64320257225074 +Zeit: 2024-10-08 10:00:00, DC: 1427.631719987348, AC: 1142.1053759898787, Messwert: None, AC GET: 1142.1053759898787 +Zeit: 2024-10-08 11:00:00, DC: 2290.204548762777, AC: 1832.163639010222, Messwert: None, AC GET: 1832.163639010222 +Zeit: 2024-10-08 12:00:00, DC: 3171.3560346447457, AC: 2537.0848277157966, Messwert: None, AC GET: 2537.0848277157966 +Zeit: 2024-10-08 13:00:00, DC: 4321.752228255224, AC: 3457.401782604179, Messwert: None, AC GET: 3457.401782604179 +Zeit: 2024-10-08 14:00:00, DC: 3827.6262786116013, AC: 3062.101022889281, Messwert: None, AC GET: 3062.101022889281 +Zeit: 2024-10-08 15:00:00, DC: 3160.087490010488, AC: 2528.0699920083907, Messwert: None, AC GET: 2528.0699920083907 +Zeit: 2024-10-08 16:00:00, DC: 2140.5172010214637, AC: 1712.4137608171711, Messwert: None, AC GET: 1712.4137608171711 +Zeit: 2024-10-08 17:00:00, DC: 1527.3895384054126, AC: 1221.9116307243303, Messwert: None, AC GET: 1221.9116307243303 +Zeit: 2024-10-08 18:00:00, DC: 871.7753606528443, AC: 697.4202885222755, Messwert: None, AC GET: 697.4202885222755 +Zeit: 2024-10-08 19:00:00, DC: 197.71223085186438, AC: 158.16978468149154, Messwert: None, AC GET: 158.16978468149154 +Zeit: 2024-10-08 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-08 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 08:00:00, DC: 2.5742724878738605, AC: 2.0594179902990883, Messwert: None, AC GET: 2.0594179902990883 +Zeit: 2024-10-09 09:00:00, DC: 138.89041623325699, AC: 111.1123329866056, Messwert: None, AC GET: 111.1123329866056 +Zeit: 2024-10-09 10:00:00, DC: 442.0646392614557, AC: 353.65171140916453, Messwert: None, AC GET: 353.65171140916453 +Zeit: 2024-10-09 11:00:00, DC: 901.4310408000806, AC: 721.1448326400645, Messwert: None, AC GET: 721.1448326400645 +Zeit: 2024-10-09 12:00:00, DC: 1343.817631464563, AC: 1075.0541051716505, Messwert: None, AC GET: 1075.0541051716505 +Zeit: 2024-10-09 13:00:00, DC: 1235.4817012708959, AC: 988.3853610167168, Messwert: None, AC GET: 988.3853610167168 +Zeit: 2024-10-09 14:00:00, DC: 918.466198606648, AC: 734.7729588853184, Messwert: None, AC GET: 734.7729588853184 +Zeit: 2024-10-09 15:00:00, DC: 520.5546485716002, AC: 416.4437188572802, Messwert: None, AC GET: 416.4437188572802 +Zeit: 2024-10-09 16:00:00, DC: 403.2086374448306, AC: 322.56690995586445, Messwert: None, AC GET: 322.56690995586445 +Zeit: 2024-10-09 17:00:00, DC: 269.0971481593191, AC: 215.27771852745528, Messwert: None, AC GET: 215.27771852745528 +Zeit: 2024-10-09 18:00:00, DC: 160.38853688287293, AC: 128.31082950629835, Messwert: None, AC GET: 128.31082950629835 +Zeit: 2024-10-09 19:00:00, DC: 35.94208390736801, AC: 28.753667125894406, Messwert: None, AC GET: 28.753667125894406 +Zeit: 2024-10-09 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-09 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 08:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 09:00:00, DC: 70.9936987722329, AC: 56.79495901778633, Messwert: None, AC GET: 56.79495901778633 +Zeit: 2024-10-10 10:00:00, DC: 208.63865710086668, AC: 166.91092568069337, Messwert: None, AC GET: 166.91092568069337 +Zeit: 2024-10-10 11:00:00, DC: 387.9199717226425, AC: 310.335977378114, Messwert: None, AC GET: 310.335977378114 +Zeit: 2024-10-10 12:00:00, DC: 1919.665578381796, AC: 1535.7324627054368, Messwert: None, AC GET: 1535.7324627054368 +Zeit: 2024-10-10 13:00:00, DC: 1103.2342605959298, AC: 882.5874084767438, Messwert: None, AC GET: 882.5874084767438 +Zeit: 2024-10-10 14:00:00, DC: 2628.0291700044304, AC: 2102.4233360035446, Messwert: None, AC GET: 2102.4233360035446 +Zeit: 2024-10-10 15:00:00, DC: 2325.6643189163906, AC: 1860.5314551331126, Messwert: None, AC GET: 1860.5314551331126 +Zeit: 2024-10-10 16:00:00, DC: 1619.6896864930636, AC: 1295.751749194451, Messwert: None, AC GET: 1295.751749194451 +Zeit: 2024-10-10 17:00:00, DC: 1238.7267357864635, AC: 990.9813886291707, Messwert: None, AC GET: 990.9813886291707 +Zeit: 2024-10-10 18:00:00, DC: 831.5919590566375, AC: 665.27356724531, Messwert: None, AC GET: 665.27356724531 +Zeit: 2024-10-10 19:00:00, DC: 153.0017240639474, AC: 122.40137925115792, Messwert: None, AC GET: 122.40137925115792 +Zeit: 2024-10-10 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-10 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 08:00:00, DC: 2.6222285230061244, AC: 2.0977828184049, Messwert: None, AC GET: 2.0977828184049 +Zeit: 2024-10-11 09:00:00, DC: 327.0591156174436, AC: 261.6472924939549, Messwert: None, AC GET: 261.6472924939549 +Zeit: 2024-10-11 10:00:00, DC: 1151.3464680423976, AC: 921.0771744339181, Messwert: None, AC GET: 921.0771744339181 +Zeit: 2024-10-11 11:00:00, DC: 3403.1305734949497, AC: 2722.5044587959596, Messwert: None, AC GET: 2722.5044587959596 +Zeit: 2024-10-11 12:00:00, DC: 4994.314478762743, AC: 3995.451583010195, Messwert: None, AC GET: 3995.451583010195 +Zeit: 2024-10-11 13:00:00, DC: 5121.461754652362, AC: 4097.1694037218895, Messwert: None, AC GET: 4097.1694037218895 +Zeit: 2024-10-11 14:00:00, DC: 3975.0670223226057, AC: 3180.0536178580846, Messwert: None, AC GET: 3180.0536178580846 +Zeit: 2024-10-11 15:00:00, DC: 3493.274881906432, AC: 2794.6199055251454, Messwert: None, AC GET: 2794.6199055251454 +Zeit: 2024-10-11 16:00:00, DC: 1744.5344793073837, AC: 1395.627583445907, Messwert: None, AC GET: 1395.627583445907 +Zeit: 2024-10-11 17:00:00, DC: 1405.205675341826, AC: 1124.164540273461, Messwert: None, AC GET: 1124.164540273461 +Zeit: 2024-10-11 18:00:00, DC: 749.0031661781884, AC: 599.2025329425508, Messwert: None, AC GET: 599.2025329425508 +Zeit: 2024-10-11 19:00:00, DC: 154.82154503859067, AC: 123.85723603087254, Messwert: None, AC GET: 123.85723603087254 +Zeit: 2024-10-11 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-11 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 08:00:00, DC: 2.647094615296928, AC: 2.1176756922375426, Messwert: None, AC GET: 2.1176756922375426 +Zeit: 2024-10-12 09:00:00, DC: 488.8155531584445, AC: 391.05244252675567, Messwert: None, AC GET: 391.05244252675567 +Zeit: 2024-10-12 10:00:00, DC: 1366.6831332414504, AC: 1093.3465065931605, Messwert: None, AC GET: 1093.3465065931605 +Zeit: 2024-10-12 11:00:00, DC: 2221.0044689711904, AC: 1776.803575176952, Messwert: None, AC GET: 1776.803575176952 +Zeit: 2024-10-12 12:00:00, DC: 4794.297312124362, AC: 3835.4378496994896, Messwert: None, AC GET: 3835.4378496994896 +Zeit: 2024-10-12 13:00:00, DC: 4291.706152495934, AC: 3433.364921996747, Messwert: None, AC GET: 3433.364921996747 +Zeit: 2024-10-12 14:00:00, DC: 3602.495502815165, AC: 2881.996402252132, Messwert: None, AC GET: 2881.996402252132 +Zeit: 2024-10-12 15:00:00, DC: 2889.920147477597, AC: 2311.9361179820776, Messwert: None, AC GET: 2311.9361179820776 +Zeit: 2024-10-12 16:00:00, DC: 1835.783463931089, AC: 1468.6267711448713, Messwert: None, AC GET: 1468.6267711448713 +Zeit: 2024-10-12 17:00:00, DC: 1206.9973161263288, AC: 965.5978529010631, Messwert: None, AC GET: 965.5978529010631 +Zeit: 2024-10-12 18:00:00, DC: 436.98494395762776, AC: 349.58795516610223, Messwert: None, AC GET: 349.58795516610223 +Zeit: 2024-10-12 19:00:00, DC: 67.60866005620485, AC: 54.086928044963884, Messwert: None, AC GET: 54.086928044963884 +Zeit: 2024-10-12 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-12 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 08:00:00, DC: 2.629333120803497, AC: 2.1034664966427976, Messwert: None, AC GET: 2.1034664966427976 +Zeit: 2024-10-13 09:00:00, DC: 360.35011327486654, AC: 288.2800906198932, Messwert: None, AC GET: 288.2800906198932 +Zeit: 2024-10-13 10:00:00, DC: 1042.6260475555746, AC: 834.1008380444597, Messwert: None, AC GET: 834.1008380444597 +Zeit: 2024-10-13 11:00:00, DC: 1394.2770921302458, AC: 1115.4216737041968, Messwert: None, AC GET: 1115.4216737041968 +Zeit: 2024-10-13 12:00:00, DC: 2399.8313632276004, AC: 1919.8650905820803, Messwert: None, AC GET: 1919.8650905820803 +Zeit: 2024-10-13 13:00:00, DC: 3958.1919497070767, AC: 3166.5535597656617, Messwert: None, AC GET: 3166.5535597656617 +Zeit: 2024-10-13 14:00:00, DC: 3367.4412350178764, AC: 2693.9529880143014, Messwert: None, AC GET: 2693.9529880143014 +Zeit: 2024-10-13 15:00:00, DC: 3169.290947155804, AC: 2535.432757724643, Messwert: None, AC GET: 2535.432757724643 +Zeit: 2024-10-13 16:00:00, DC: 1911.7740840246706, AC: 1529.4192672197366, Messwert: None, AC GET: 1529.4192672197366 +Zeit: 2024-10-13 17:00:00, DC: 1386.220592426786, AC: 1108.9764739414288, Messwert: None, AC GET: 1108.9764739414288 +Zeit: 2024-10-13 18:00:00, DC: 575.9733531838393, AC: 460.77868254707147, Messwert: None, AC GET: 460.77868254707147 +Zeit: 2024-10-13 19:00:00, DC: 78.56691565855094, AC: 62.85353252684076, Messwert: None, AC GET: 62.85353252684076 +Zeit: 2024-10-13 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-13 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 08:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 09:00:00, DC: 328.9765131894571, AC: 263.18121055156564, Messwert: None, AC GET: 263.18121055156564 +Zeit: 2024-10-14 10:00:00, DC: 1003.7648088236231, AC: 803.0118470588985, Messwert: None, AC GET: 803.0118470588985 +Zeit: 2024-10-14 11:00:00, DC: 1530.7257802155832, AC: 1224.5806241724667, Messwert: None, AC GET: 1224.5806241724667 +Zeit: 2024-10-14 12:00:00, DC: 1346.3074573320503, AC: 1077.0459658656403, Messwert: None, AC GET: 1077.0459658656403 +Zeit: 2024-10-14 13:00:00, DC: 1875.1615396721313, AC: 1500.1292317377051, Messwert: None, AC GET: 1500.1292317377051 +Zeit: 2024-10-14 14:00:00, DC: 1940.5354145494168, AC: 1552.4283316395336, Messwert: None, AC GET: 1552.4283316395336 +Zeit: 2024-10-14 15:00:00, DC: 1768.1679163173571, AC: 1414.5343330538858, Messwert: None, AC GET: 1414.5343330538858 +Zeit: 2024-10-14 16:00:00, DC: 1458.7871531001254, AC: 1167.0297224801004, Messwert: None, AC GET: 1167.0297224801004 +Zeit: 2024-10-14 17:00:00, DC: 1010.7950430626171, AC: 808.6360344500937, Messwert: None, AC GET: 808.6360344500937 +Zeit: 2024-10-14 18:00:00, DC: 468.9879780808202, AC: 375.1903824646562, Messwert: None, AC GET: 375.1903824646562 +Zeit: 2024-10-14 19:00:00, DC: 78.50914028438824, AC: 62.80731222751059, Messwert: None, AC GET: 62.80731222751059 +Zeit: 2024-10-14 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-14 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 08:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 09:00:00, DC: 400.8076493987202, AC: 320.64611951897615, Messwert: None, AC GET: 320.64611951897615 +Zeit: 2024-10-15 10:00:00, DC: 1135.8607353766422, AC: 908.6885883013138, Messwert: None, AC GET: 908.6885883013138 +Zeit: 2024-10-15 11:00:00, DC: 2075.472520835528, AC: 1660.3780166684228, Messwert: None, AC GET: 1660.3780166684228 +Zeit: 2024-10-15 12:00:00, DC: 3664.671214337204, AC: 2931.7369714697634, Messwert: None, AC GET: 2931.7369714697634 +Zeit: 2024-10-15 13:00:00, DC: 6061.0533141581545, AC: 4848.842651326524, Messwert: None, AC GET: 4848.842651326524 +Zeit: 2024-10-15 14:00:00, DC: 4141.306741623349, AC: 3313.0453932986793, Messwert: None, AC GET: 3313.0453932986793 +Zeit: 2024-10-15 15:00:00, DC: 3620.4523441068936, AC: 2896.361875285515, Messwert: None, AC GET: 2896.361875285515 +Zeit: 2024-10-15 16:00:00, DC: 1666.2167974329839, AC: 1332.973437946387, Messwert: None, AC GET: 1332.973437946387 +Zeit: 2024-10-15 17:00:00, DC: 1144.0387748455216, AC: 915.2310198764173, Messwert: None, AC GET: 915.2310198764173 +Zeit: 2024-10-15 18:00:00, DC: 620.723373108851, AC: 496.57869848708077, Messwert: None, AC GET: 496.57869848708077 +Zeit: 2024-10-15 19:00:00, DC: 132.65653839949636, AC: 106.1252307195971, Messwert: None, AC GET: 106.1252307195971 +Zeit: 2024-10-15 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-15 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 08:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 09:00:00, DC: 300.0660361917257, AC: 240.05282895338058, Messwert: None, AC GET: 240.05282895338058 +Zeit: 2024-10-16 10:00:00, DC: 868.5128047099465, AC: 694.8102437679572, Messwert: None, AC GET: 694.8102437679572 +Zeit: 2024-10-16 11:00:00, DC: 1555.4453842802168, AC: 1244.3563074241736, Messwert: None, AC GET: 1244.3563074241736 +Zeit: 2024-10-16 12:00:00, DC: 2675.3832192006007, AC: 2140.3065753604806, Messwert: None, AC GET: 2140.3065753604806 +Zeit: 2024-10-16 13:00:00, DC: 4191.900905711933, AC: 3353.520724569547, Messwert: None, AC GET: 3353.520724569547 +Zeit: 2024-10-16 14:00:00, DC: 3386.3457857410863, AC: 2709.076628592869, Messwert: None, AC GET: 2709.076628592869 +Zeit: 2024-10-16 15:00:00, DC: 2980.635337976421, AC: 2384.508270381137, Messwert: None, AC GET: 2384.508270381137 +Zeit: 2024-10-16 16:00:00, DC: 1728.6316869128443, AC: 1382.9053495302755, Messwert: None, AC GET: 1382.9053495302755 +Zeit: 2024-10-16 17:00:00, DC: 1143.3109041676573, AC: 914.6487233341259, Messwert: None, AC GET: 914.6487233341259 +Zeit: 2024-10-16 18:00:00, DC: 567.6444132249978, AC: 454.11553057999834, Messwert: None, AC GET: 454.11553057999834 +Zeit: 2024-10-16 19:00:00, DC: 116.3312759656061, AC: 93.06502077248489, Messwert: None, AC GET: 93.06502077248489 +Zeit: 2024-10-16 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-16 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 00:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 01:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 02:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 03:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 04:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 05:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 06:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 07:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 08:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 09:00:00, DC: 271.7219822789749, AC: 217.3775858231799, Messwert: None, AC GET: 217.3775858231799 +Zeit: 2024-10-17 10:00:00, DC: 809.4024309606523, AC: 647.5219447685218, Messwert: None, AC GET: 647.5219447685218 +Zeit: 2024-10-17 11:00:00, DC: 1387.6787429050005, AC: 1110.1429943240005, Messwert: None, AC GET: 1110.1429943240005 +Zeit: 2024-10-17 12:00:00, DC: 1990.9522710681556, AC: 1592.7618168545246, Messwert: None, AC GET: 1592.7618168545246 +Zeit: 2024-10-17 13:00:00, DC: 2147.642554452618, AC: 1718.1140435620944, Messwert: None, AC GET: 1718.1140435620944 +Zeit: 2024-10-17 14:00:00, DC: 2158.8258001478607, AC: 1727.0606401182886, Messwert: None, AC GET: 1727.0606401182886 +Zeit: 2024-10-17 15:00:00, DC: 2027.8192437466564, AC: 1622.2553949973253, Messwert: None, AC GET: 1622.2553949973253 +Zeit: 2024-10-17 16:00:00, DC: 1626.5734434020972, AC: 1301.2587547216779, Messwert: None, AC GET: 1301.2587547216779 +Zeit: 2024-10-17 17:00:00, DC: 1165.2889667744384, AC: 932.2311734195508, Messwert: None, AC GET: 932.2311734195508 +Zeit: 2024-10-17 18:00:00, DC: 573.52703976922, AC: 458.821631815376, Messwert: None, AC GET: 458.821631815376 +Zeit: 2024-10-17 19:00:00, DC: 102.43605685684926, AC: 81.94884548547941, Messwert: None, AC GET: 81.94884548547941 +Zeit: 2024-10-17 20:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 21:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 22:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 +Zeit: 2024-10-17 23:00:00, DC: 0, AC: 0, Messwert: None, AC GET: 0 From 1f5abf391bce95126089279533fb68c7581f3eab Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Sat, 19 Oct 2024 18:27:00 +0200 Subject: [PATCH 21/21] Tool to integrate EOS PRs in an integration branch. This script automates the integration of multiple GitHub pull requests (PRs) into a specified integration branch. It fetches each PR, creates a branch from the PR, rebases the branch onto the integration branch, and then merges it back into the integration branch. The process is logged to a branch-specific log file, and if any step fails, the script exits and saves the current progress (PR number and step) to a branch-specific JSON file. The script can be rerun, and it will resume from where the last step failed. If the script is run again on the same branch without providing PR numbers, it will reuse the PR numbers from the first run. Signed-off-by: Bobby Noelte --- tools/integrate_prs.py | 466 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tools/integrate_prs.py diff --git a/tools/integrate_prs.py b/tools/integrate_prs.py new file mode 100644 index 00000000..edbc0fc3 --- /dev/null +++ b/tools/integrate_prs.py @@ -0,0 +1,466 @@ +"""Integrates multiple GitHub pull requests (PRs) into a specified integration branch. + +This script automates the integration of multiple GitHub pull requests (PRs) +into a specified integration branch. It fetches each PR, creates a branch +from the PR, rebases the branch onto the integration branch, and then merges +it back into the integration branch. The process is logged to a branch-specific +log file, and if any step fails, the script exits and saves the current progress +(PR number and step) to a branch-specific JSON file. The script can be rerun, +and it will resume from where the last step failed. If the script is run again +on the same branch without providing PR numbers, it will reuse the PR numbers +from the first run. + +Additionally, the script provides an option to delete all branches created +during the integration process by passing "delete" or "D" as an argument. + +Steps: + 1. Fetch the pull request into a local branch. + 2. Create a branch from the fetched PR and rebase it onto the integration branch. + 3. Test the rebased branch. + 4. Merge the rebased branch into the integration branch. + 5. Optionally, delete all the branches created during the integration process. + +If any step fails, the process will exit, and the failure point is logged +for resumption in the next run. + +Usage: + python integrate_prs.py [ ...] + python integrate_prs.py --delete + +Example: + python integrate_prs.py pr_integration 123 456 789 + python integrate_prs.py pr_integration --delete + +Arguments: + integration-branch: The target branch where PRs will be integrated. + pr-number (optional): A list of pull request numbers to be fetched, rebased, + tested and merged. If omitted and PRs were previously run on this branch, + the remembered PR numbers will be used. + --reset (optional): Resets the integration branch and starts from beginning + of pull request list. Pull requestw already rebased are used without rebase. + Delete the rebase branch in case you want to trigger a new rebase. + (use "--reset" or "-r"). + --delete (optional): Deletes all branches created for PR integration + (use "--delete" or "-d"). + +Progress Tracking: + - A branch-specific progress file (e.g., _progress.json) is used to + store the current PR number and step in case of failure. + - A branch-specific remembered PR file (e.g., _remembered_prs.json) + stores the list of PR numbers from the first run. + - On the next run, the script reads from this file and continues with the + remembered PR numbers. + +Files: + - _progress.json: Stores the last failed PR number and step. + - _remembered_prs.json: Stores the list of PR numbers from the first run. + - _integration.log: Logs the integration process and any errors + encountered for a specific branch. + +Exit Codes: + - 0: Success. + - 1: Failure during any step (fetch, rebase, or merge). + - 2: Failure during branch deletion. +""" + +import json +import logging +import os +import subprocess +import sys + +EOS_GITHUB_URL = "https://github.com/Akkudoktor-EOS/EOS" + + +# Progress and log files are now prefixed by the integration branch name +def get_progress_file(integration_branch): + return f"{integration_branch}_progress.json" + + +def get_remembered_prs_file(integration_branch): + return f"{integration_branch}_remembered_prs.json" + + +def get_log_file(integration_branch): + return f"{integration_branch}_integration.log" + + +def setup_logging(integration_branch): + """Sets up logging to a branch-specific file and stdout.""" + # Create a logger + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + # Create file handler + file_handler = logging.FileHandler(get_log_file(integration_branch)) + file_handler.setLevel(logging.INFO) + + # Create console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # Create formatter + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + # Add handlers to logger + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + +def run_command(command, cwd=None): + """Run a shell command and log the output.""" + logging.info(f"Running command: {' '.join(command)}") + try: + result = subprocess.run( + command, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + logging.info(f"Command output: {result.stdout.decode()}") + except subprocess.CalledProcessError as e: + logging.error(f"Command failed with error: {e.stderr.decode()}") + return False + return True + + +def branch_exists(branch_name): + """Check if a branch exists locally.""" + result = subprocess.run(["git", "branch", "--list", branch_name], stdout=subprocess.PIPE) + return branch_name in result.stdout.decode() + + +def create_integration_branch_if_needed(integration_branch): + """Create the integration branch if it does not exist.""" + if not branch_exists(integration_branch): + logging.info(f"Integration branch '{integration_branch}' does not exist. Creating it.") + # Create the integration branch from 'main' or 'master' + if branch_exists("main"): + default_branch = "main" + elif branch_exists("master"): + default_branch = "master" + else: + logging.error("Neither 'main' nor 'master' branch found.") + sys.exit(1) + + if not run_command(["git", "switch", default_branch]): + logging.error(f"Failed to switch to branch {default_branch}") + return False + + # Assure local default_branch is on remote default_branch + if not run_command(["git", "pull", f"{EOS_GITHUB_URL}", default_branch]): + logging.error(f"Failed to pull main from {EOS_GITHUB_URL}.") + return False + + if not run_command(["git", "checkout", "-b", integration_branch, default_branch]): + logging.error( + f"Failed to create integration branch {integration_branch} from {default_branch}" + ) + sys.exit(1) + else: + logging.info(f"Integration branch '{integration_branch}' exists.") + + +def fetch_pr(pr_number): + """Fetch the pull request into a local branch.""" + pr_branch = f"pr-{pr_number}" + logging.info(f"Fetching PR #{pr_number} into branch {pr_branch}") + + # Fetch the pull request and create a new branch for it + if not run_command(["git", "fetch", f"{EOS_GITHUB_URL}", f"pull/{pr_number}/head:{pr_branch}"]): + logging.error(f"Failed to fetch PR #{pr_number}") + return None + return pr_branch + + +def create_and_rebase_branch(pr_branch, integration_branch): + """Create a branch from the PR branch and rebase it onto the integration branch.""" + rebase_branch = f"{integration_branch}-rebase-{pr_branch}" + logging.info( + f"Creating and rebasing {rebase_branch} from {pr_branch} onto {integration_branch}" + ) + + # Create a new branch from the PR branch + if branch_exists(rebase_branch): + if not run_command(["git", "switch", rebase_branch]): + logging.error(f"Failed to switch to branch {rebase_branch}") + return False + logging.info(f"Rebase branch '{rebase_branch}' exists.") + else: + if not run_command(["git", "switch", "-c", rebase_branch, pr_branch]): + logging.error(f"Failed to create branch {rebase_branch}") + return False + + # Rebase the new branch onto the integration branch + if not run_command(["git", "rebase", integration_branch]): + logging.error(f"Failed to rebase {rebase_branch} onto {integration_branch}") + return False + + return rebase_branch + + +def test_branch(rebase_branch, integration_branch): + """Test the rebased branch.""" + logging.info(f"Testing {rebase_branch}") + + # Checkout the rebased branch + if not run_command(["git", "checkout", rebase_branch]): + logging.error(f"Failed to checkout {rebase_branch}.") + return False + + # Test the rebased branch + if not run_command([".venv/bin/pytest", "-vs", "--cov", "src", "--cov-report", "term-missing"]): + logging.error(f"Failed to test {rebase_branch}.") + return False + + return True + + +def merge_branch(rebase_branch, integration_branch): + """Merge the rebased branch into the integration branch.""" + logging.info(f"Merging {rebase_branch} into {integration_branch}") + + # Checkout the integration branch + if not run_command(["git", "checkout", integration_branch]): + logging.error(f"Failed to checkout {integration_branch}") + return False + + # Merge the rebased branch into the integration branch + if not run_command(["git", "merge", "--no-ff", rebase_branch]): + logging.error(f"Failed to merge {rebase_branch} into {integration_branch}") + return False + + return True + + +def reset_integration_branch(integration_branch): + """Reset the integration branch to the main branch.""" + logging.info(f"Resetting {integration_branch} to main branch.") + + # Checkout the main branch + if not run_command(["git", "checkout", "main"]): + logging.error("Failed to checkout main branch.") + return False + + # Assure local main is on remote main + if not run_command(["git", "pull", f"{EOS_GITHUB_URL}", "main"]): + logging.error(f"Failed to pull main from {EOS_GITHUB_URL}.") + return False + + # Reset the integration branch to main + if not run_command(["git", "branch", "-D", integration_branch]): + logging.error(f"Failed to delete the integration branch {integration_branch}.") + return False + + # Create a new integration branch from main + if not run_command(["git", "checkout", "-b", integration_branch]): + logging.error(f"Failed to create a new integration branch {integration_branch} from main.") + return False + + return True + + +def save_progress(step, pr_number, integration_branch): + """Save the current step and PR number to the branch-specific progress file.""" + progress_file = get_progress_file(integration_branch) + with open(progress_file, "w") as f: + json.dump({"step": step, "pr_number": pr_number}, f) + + +def load_progress(integration_branch): + """Load the last saved step and PR number from the branch-specific progress file.""" + progress_file = get_progress_file(integration_branch) + if os.path.exists(progress_file): + with open(progress_file, "r") as f: + return json.load(f) + return None + + +def save_remembered_prs(pr_numbers, integration_branch): + """Save the list of PR numbers to the branch-specific remembered PR file.""" + remembered_prs_file = get_remembered_prs_file(integration_branch) + with open(remembered_prs_file, "w") as f: + json.dump(pr_numbers, f) + + +def load_remembered_prs(integration_branch): + """Load the list of PR numbers from the branch-specific remembered PR file.""" + remembered_prs_file = get_remembered_prs_file(integration_branch) + if os.path.exists(remembered_prs_file): + with open(remembered_prs_file, "r") as f: + return json.load(f) + return None + + +def delete_created_branches(pr_numbers, integration_branch): + """Delete all branches created during the integration process.""" + logging.info(f"Deleting branches created for integration of PRs: {pr_numbers}") + + for pr_number in pr_numbers: + pr_branch = f"pr-{pr_number}" + rebase_branch = f"{integration_branch}-rebase-pr-{pr_number}" + + # Delete the PR branch + if branch_exists(pr_branch): + if not run_command(["git", "branch", "-D", pr_branch]): + logging.error(f"Failed to delete branch {pr_branch}") + return False + + # Delete the rebase branch + if branch_exists(rebase_branch): + if not run_command(["git", "branch", "-D", rebase_branch]): + logging.error(f"Failed to delete branch {rebase_branch}") + return False + + logging.info("All created branches have been deleted.") + return True + + +def delete_log_files(integration_branch): + """Delete all log files related to the integration branch.""" + logging.info(f"Deleting log files for integration branch '{integration_branch}'.") + files_to_delete = [ + get_progress_file(integration_branch), + get_remembered_prs_file(integration_branch), + get_log_file(integration_branch), + ] + + for file in files_to_delete: + try: + if os.path.exists(file): + os.remove(file) + logging.info(f"Deleted file: {file}") + else: + logging.warning(f"File not found: {file}") + except Exception as e: + logging.error(f"Error deleting file {file}: {str(e)}") + + +def integrate_prs(pr_numbers, integration_branch): + """Main function to integrate pull requests into an integration branch.""" + logging.info(f"Starting integration process for PRs: {pr_numbers} into {integration_branch}") + + remembered_prs = load_remembered_prs(integration_branch) + + # Load and or save remembered PRs + if not pr_numbers: + pr_numbers = remembered_prs + if not pr_numbers: + logging.error( + f"No PR numbers provided and no remembered PRs found for branch {integration_branch}." + ) + sys.exit(1) + + if remembered_prs and remembered_prs != pr_numbers: + logging.warning( + f"Other PR numbers provided than remembered PRs {remembered_prs} found for branch {integration_branch}." + ) + logging.warning( + f"Restarting integration process for PRs: {pr_numbers} into {integration_branch}." + ) + delete_log_files(integration_branch) + reset_integration_branch(integration_branch) + save_remembered_prs(pr_numbers, integration_branch) + elif not remembered_prs: + # Remember PR numbers on the first run + save_remembered_prs(pr_numbers, integration_branch) + + # Load progress from the last run + progress = load_progress(integration_branch) + last_step = progress["step"] if progress else None + last_pr = progress["pr_number"] if progress else None + + if last_step: + logging.info(f"Resuming from step {last_step} for PR #{last_pr}") + + # Create or checkout the integration branch + create_integration_branch_if_needed(integration_branch) + + for pr_number in pr_numbers: + if last_pr and pr_number < last_pr: + # Skip PRs processed before the last failure + logging.info(f"Skipping PR #{pr_number}. Already processed.") + continue + + logging.info(f"Processing PR #{pr_number}") + + # Step 1: Fetch PR + if not last_step or last_step == "fetch": + pr_branch = fetch_pr(pr_number) + if pr_branch is None: + save_progress("fetch", pr_number, integration_branch) + sys.exit(1) + else: + pr_branch = f"pr-{pr_number}" + + # Step 2: Rebase PR onto integration branch + if not last_step or last_step == "rebase": + rebase_branch = create_and_rebase_branch(pr_branch, integration_branch) + if not rebase_branch: + save_progress("rebase", pr_number, integration_branch) + sys.exit(1) + else: + rebase_branch = f"{integration_branch}-rebase-{pr_branch}" + + # Step 3: Test rebased branch + if not last_step or last_step == "test": + if not test_branch(rebase_branch, integration_branch): + save_progress("test", pr_number, integration_branch) + sys.exit(1) + + # Step 4: Merge rebased branch into integration branch + if not last_step or last_step == "merge": + if not merge_branch(rebase_branch, integration_branch): + save_progress("merge", pr_number, integration_branch) + sys.exit(1) + + last_step = None + + logging.info(f"All PRs {pr_numbers} successfully integrated into {integration_branch}.") + + +def main(): + if len(sys.argv) < 2: + print("Usage: python integrate_prs.py [ ...]") + sys.exit(1) + + integration_branch = sys.argv[1] + pr_numbers = [int(pr) for pr in sys.argv[2:] if pr.isdigit()] + delete_flag = ( + sys.argv[2].lower() + if len(sys.argv) > 2 and sys.argv[2].lower() in ["-d", "--delete"] + else None + ) + reset_flag = ( + sys.argv[2].lower() + if len(sys.argv) > 2 and sys.argv[2].lower() in ["-r", "--reset"] + else None + ) + + # Setup logging + setup_logging(integration_branch) + + if delete_flag: + # If delete option is passed, delete the created branches + pr_numbers = load_remembered_prs(integration_branch) + if pr_numbers: + if delete_created_branches(pr_numbers, integration_branch): + logging.info( + f"All branches related to PRs {pr_numbers} for {integration_branch} have been deleted." + ) + sys.exit(0) + else: + logging.error("Failed to delete some branches.") + sys.exit(2) + else: + logging.error(f"No remembered PRs found for branch {integration_branch}.") + sys.exit(1) + elif reset_flag: + pr_numbers = load_remembered_prs(integration_branch) + reset_integration_branch(integration_branch) + + # Perform the PR integration process + integrate_prs(pr_numbers, integration_branch) + + +if __name__ == "__main__": + main()