From ed3226e522785e4517478e11a650cc5dcc3c6ef6 Mon Sep 17 00:00:00 2001 From: Chris <48966114+noootch@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:38:13 +0100 Subject: [PATCH] Json configuration (#141) * Add json config * Adjust code to new config --------- Co-authored-by: Chris --- .gitignore | 1 + README.md | 25 +- docker-compose.yaml | 2 +- docs/develop/getting_started.md | 26 +- pyproject.toml | 1 + single_test_optimization.py | 35 +-- src/akkudoktoreos/class_ems.py | 9 +- src/akkudoktoreos/class_optimize.py | 51 ++-- src/akkudoktoreos/class_strompreis.py | 46 ++-- src/akkudoktoreos/config.py | 304 ++++++++++++++++++++++-- src/akkudoktoreos/default.config.json | 15 ++ src/akkudoktoreos/visualize.py | 27 ++- src/akkudoktoreosserver/flask_server.py | 46 ++-- tests/conftest.py | 23 +- tests/test_class_ems.py | 6 +- tests/test_class_ems_2.py | 6 +- tests/test_class_optimize.py | 21 +- tests/test_config.py | 71 ++++++ tests/test_server.py | 22 +- tests/test_visualize.py | 12 +- tests/testdata/visualize_input_1.json | 148 ++++++++++-- 21 files changed, 726 insertions(+), 171 deletions(-) create mode 100644 src/akkudoktoreos/default.config.json create mode 100644 tests/test_config.py diff --git a/.gitignore b/.gitignore index 86c71d7..495124d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ cache/ output/ +EOS.config.json # Default ignore folders and files for VS Code, Python diff --git a/README.md b/README.md index cb1b53e..9dbfcc2 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,29 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Installation Good installation guide: -https://meintechblog.de/2024/09/05/andreas-schmitz-joerg-installiert-mein-energieoptimierungssystem/ + The project requires Python 3.10 or newer. +## Configuration + +This project uses a `config.json` file to manage configuration settings. + +### Default Configuration + +A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values. + +### Custom Configuration + +Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`. + +- If the directory specified by `EOS_DIR` contains an existing `config.json` file, the application will use this configuration file. +- If the `config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `config.json`. + +### Configuration Updates + +If the configuration keys in the `config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present. + ### Quick Start Guide On Linux (Ubuntu/Debian): @@ -27,8 +46,7 @@ On MacOS (requires [Homebrew](https://brew.sh)): brew install make ``` -Next, adjust `config.py`. -The server can then be started with `make run`. A full overview of the main shortcuts is given by `make help`. +The server can be started with `make run`. A full overview of the main shortcuts is given by `make help`. ### Detailed Instructions @@ -65,7 +83,6 @@ source .venv/bin/activate ## Usage -Adjust `config.py`. To use the system, run `flask_server.py`, which starts the server: ```bash diff --git a/docker-compose.yaml b/docker-compose.yaml index 6939c8f..b3b6812 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,6 +17,6 @@ services: networks: - "eos" volumes: - - ./src/akkudoktoreos/config.py:/opt/eos/akkudoktoreos/config.py:ro + - ./src/akkudoktoreos/default.config.json:/opt/eos/EOS.config.json:ro ports: - "${EOS_PORT}:${EOS_PORT}" diff --git a/docs/develop/getting_started.md b/docs/develop/getting_started.md index 406c973..d6dc72c 100644 --- a/docs/develop/getting_started.md +++ b/docs/develop/getting_started.md @@ -23,7 +23,6 @@ On MacOS (requires [Homebrew](https://brew.sh)): brew install make ``` -Next, adjust `config.py`. The server can then be started with `make run`. A full overview of the main shortcuts is given by `make help`. ### Detailed Instructions @@ -52,6 +51,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 @@ -60,7 +60,29 @@ source .venv/bin/activate ## Usage -Adjust `config.py`. +### Configuration + +--- + +This project uses a `config.json` file to manage configuration settings. + +#### Default Configuration + +A default configuration file `default.config.json` is provided. This file contains all the necessary configuration keys with their default values. + +#### Custom Configuration + +Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`. + +- If the directory specified by `EOS_DIR` contains an existing `config.json` file, the application will use this configuration file. +- If the `config.json` file does not exist in the specified directory, the `default.config.json` file will be copied to the directory as `config.json`. + +#### Configuration Updates + +If the configuration keys in the `config.json` file are missing or different from those in `default.config.json`, they will be automatically updated to match the default settings, ensuring that all required keys are present. + +### Run server + To use the system, run `flask_server.py`, which starts the server: ```bash diff --git a/pyproject.toml b/pyproject.toml index 18c870f..5ed8519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ where = ["src/"] include = ["akkudoktoreos", "akkudoktoreosserver", ] [tool.setuptools.package-data] +akkudoktoreos = ["*.json", ] akkudoktoreosserver = ["data/*.npz", ] [tool.pyright] diff --git a/single_test_optimization.py b/single_test_optimization.py index 0ae3f1e..3b63c2e 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -5,9 +5,8 @@ import time import numpy as np from akkudoktoreos.class_numpy_encoder import NumpyEncoder - -# Import necessary modules from the project from akkudoktoreos.class_optimize import optimization_problem +from akkudoktoreos.config import get_working_dir, load_config from akkudoktoreos.visualize import visualisiere_ergebnisse start_hour = 0 @@ -276,10 +275,10 @@ parameter = { # Startzeit nehmen start_time = time.time() -# Initialize the optimization problem -opt_class = optimization_problem( - prediction_hours=48, strafe=10, optimization_hours=24, verbose=True, fixed_seed=42 -) +# Initialize the optimization problem using the default configuration +working_dir = get_working_dir() +config = load_config(working_dir) +opt_class = optimization_problem(config, verbose=True, fixed_seed=42) # Perform the optimisation based on the provided parameters and start hour ergebnis = opt_class.optimierung_ems(parameter=parameter, start_hour=start_hour) @@ -299,17 +298,19 @@ ac_charge, dc_charge, discharge = ( ) visualisiere_ergebnisse( - gesamtlast, - pv_forecast, - strompreis_euro_pro_wh, - ergebnis["result"], - ac_charge, - dc_charge, - discharge, - temperature_forecast, - start_hour, - 48, - np.full(48, parameter["einspeiseverguetung_euro_pro_wh"]), + gesamtlast=gesamtlast, + pv_forecast=pv_forecast, + strompreise=strompreis_euro_pro_wh, + ergebnisse=ergebnis["result"], + ac=ac_charge, + dc=dc_charge, + discharge=discharge, + temperature=temperature_forecast, + start_hour=start_hour, + einspeiseverguetung_euro_pro_wh=np.full( + config.eos.feed_in_tariff_eur_per_wh, parameter["einspeiseverguetung_euro_pro_wh"] + ), + config=config, filename="visualization_results.pdf", extra_data=None, ) diff --git a/src/akkudoktoreos/class_ems.py b/src/akkudoktoreos/class_ems.py index 9900919..e7ff21b 100644 --- a/src/akkudoktoreos/class_ems.py +++ b/src/akkudoktoreos/class_ems.py @@ -3,12 +3,13 @@ from typing import Dict, List, Optional, Union import numpy as np -from akkudoktoreos.config import prediction_hours +from akkudoktoreos.config import EOSConfig class EnergieManagementSystem: def __init__( self, + config: EOSConfig, pv_prognose_wh: Optional[np.ndarray] = None, strompreis_euro_pro_wh: Optional[np.ndarray] = None, einspeiseverguetung_euro_pro_wh: Optional[np.ndarray] = None, @@ -25,9 +26,9 @@ class EnergieManagementSystem: self.eauto = eauto self.haushaltsgeraet = haushaltsgeraet self.wechselrichter = wechselrichter - self.ac_charge_hours = np.full(prediction_hours, 0) - self.dc_charge_hours = np.full(prediction_hours, 1) - self.ev_charge_hours = np.full(prediction_hours, 0) + self.ac_charge_hours = np.full(config.prediction_hours, 0) + self.dc_charge_hours = np.full(config.prediction_hours, 1) + self.ev_charge_hours = np.full(config.prediction_hours, 0) def set_akku_discharge_hours(self, ds: List[int]) -> None: self.akku.set_discharge_per_hour(ds) diff --git a/src/akkudoktoreos/class_optimize.py b/src/akkudoktoreos/class_optimize.py index d68bcde..0f98d00 100644 --- a/src/akkudoktoreos/class_optimize.py +++ b/src/akkudoktoreos/class_optimize.py @@ -8,25 +8,24 @@ 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 akkudoktoreos.config import possible_ev_charge_currents +from akkudoktoreos.config import AppConfig from akkudoktoreos.visualize import visualisiere_ergebnisse class optimization_problem: def __init__( self, - prediction_hours: int = 48, - strafe: float = 10, - optimization_hours: int = 24, + config: AppConfig, verbose: bool = False, fixed_seed: Optional[int] = None, ): """Initialize the optimization problem with the required parameters.""" - self.prediction_hours = prediction_hours - self.strafe = strafe + self._config = config + self.prediction_hours = config.eos.prediction_hours + self.strafe = config.eos.penalty self.opti_param = None - self.fixed_eauto_hours = prediction_hours - optimization_hours - self.possible_charge_values = possible_ev_charge_currents + self.fixed_eauto_hours = config.eos.prediction_hours - config.eos.optimization_hours + self.possible_charge_values = config.eos.available_charging_rates_in_percentage self.verbose = verbose self.fix_seed = fixed_seed self.optimize_ev = True @@ -210,7 +209,10 @@ class optimization_problem: if self.optimize_ev: self.toolbox.register( - "attr_ev_charge_index", random.randint, 0, len(possible_ev_charge_currents) - 1 + "attr_ev_charge_index", + random.randint, + 0, + len(self._config.eos.available_charging_rates_in_percentage) - 1, ) self.toolbox.register("attr_int", random.randint, start_hour, 23) @@ -236,7 +238,7 @@ class optimization_problem: "mutate_ev_charge_index", tools.mutUniformInt, low=0, - up=len(possible_ev_charge_currents) - 1, + up=len(self._config.eos.available_charging_rates_in_percentage) - 1, indpb=0.2, ) # - Start hour mutation for household devices @@ -271,7 +273,8 @@ class optimization_problem: if self.optimize_ev: eautocharge_hours_float = [ - possible_ev_charge_currents[i] for i in eautocharge_hours_index + self._config.eos.available_charging_rates_in_percentage[i] + for i in eautocharge_hours_index ] ems.set_ev_charge_hours(eautocharge_hours_float) else: @@ -420,6 +423,7 @@ class optimization_problem: # Initialize the inverter and energy management system wr = Wechselrichter(10000, akku) ems = EnergieManagementSystem( + config=self._config.eos, gesamtlast=parameter["gesamtlast"], pv_prognose_wh=parameter["pv_forecast"], strompreis_euro_pro_wh=parameter["strompreis_euro_pro_wh"], @@ -444,23 +448,24 @@ class optimization_problem: ) if self.optimize_ev: eautocharge_hours_float = [ - possible_ev_charge_currents[i] for i in eautocharge_hours_float + self._config.eos.available_charging_rates_in_percentage[i] + for i in eautocharge_hours_float ] ac_charge, dc_charge, discharge = self.decode_charge_discharge(discharge_hours_bin) # Visualize the results visualisiere_ergebnisse( - parameter["gesamtlast"], - parameter["pv_forecast"], - parameter["strompreis_euro_pro_wh"], - o, - ac_charge, - dc_charge, - discharge, - parameter["temperature_forecast"], - start_hour, - self.prediction_hours, - einspeiseverguetung_euro_pro_wh, + gesamtlast=parameter["gesamtlast"], + pv_forecast=parameter["pv_forecast"], + strompreise=parameter["strompreis_euro_pro_wh"], + ergebnisse=o, + ac=ac_charge, + dc=dc_charge, + discharge=discharge, + temperature=parameter["temperature_forecast"], + start_hour=start_hour, + einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, + config=self._config, extra_data=extra_data, ) diff --git a/src/akkudoktoreos/class_strompreis.py b/src/akkudoktoreos/class_strompreis.py index b951aba..2e40375 100644 --- a/src/akkudoktoreos/class_strompreis.py +++ b/src/akkudoktoreos/class_strompreis.py @@ -1,12 +1,14 @@ import hashlib import json -import os import zoneinfo from datetime import datetime, timedelta, timezone +from pathlib import Path import numpy as np import requests +from akkudoktoreos.config import AppConfig, SetupIncomplete + def repeat_to_shape(array, target_shape): # Check if the array fits the target shape @@ -23,53 +25,57 @@ def repeat_to_shape(array, target_shape): class HourlyElectricityPriceForecast: def __init__( - self, source, cache_dir="cache", charges=0.000228, prediction_hours=24, cache=True + self, source: str | Path, config: AppConfig, charges=0.000228, use_cache=True ): # 228 - self.cache_dir = cache_dir - self.cache = cache - os.makedirs(self.cache_dir, exist_ok=True) - self.cache_time_file = os.path.join(self.cache_dir, "cache_timestamp.txt") + self.cache_dir = config.working_dir / config.directories.cache + self.use_cache = use_cache + if not self.cache_dir.is_dir(): + raise SetupIncomplete(f"Output path does not exist: {self.cache_dir}.") + + self.cache_time_file = self.cache_dir / "cache_timestamp.txt" self.prices = self.load_data(source) self.charges = charges - self.prediction_hours = prediction_hours + self.prediction_hours = config.eos.prediction_hours - def load_data(self, source): - cache_filename = self.get_cache_filename(source) - if source.startswith("http"): - if os.path.exists(cache_filename) and not self.is_cache_expired() and self.cache: + def load_data(self, source: str | Path): + cache_file = self.get_cache_file(source) + if isinstance(source, str): + if cache_file.is_file() and not self.is_cache_expired() and self.use_cache: print("Loading data from cache...") - with open(cache_filename, "r") as file: + with cache_file.open("r") as file: json_data = json.load(file) else: print("Loading data from the URL...") response = requests.get(source) if response.status_code == 200: json_data = response.json() - with open(cache_filename, "w") as file: + with cache_file.open("w") as file: json.dump(json_data, file) self.update_cache_timestamp() else: raise Exception(f"Error fetching data: {response.status_code}") - else: - with open(source, "r") as file: + elif source.is_file(): + with source.open("r") as file: json_data = json.load(file) + else: + raise ValueError(f"Input is not a valid path: {source}") return json_data["values"] - def get_cache_filename(self, url): + def get_cache_file(self, url): hash_object = hashlib.sha256(url.encode()) hex_dig = hash_object.hexdigest() - return os.path.join(self.cache_dir, f"cache_{hex_dig}.json") + return self.cache_dir / f"cache_{hex_dig}.json" def is_cache_expired(self): - if not os.path.exists(self.cache_time_file): + if not self.cache_time_file.is_file(): return True - with open(self.cache_time_file, "r") as file: + with self.cache_time_file.open("r") as file: timestamp_str = file.read() last_cache_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") return datetime.now() - last_cache_time > timedelta(hours=1) def update_cache_timestamp(self): - with open(self.cache_time_file, "w") as file: + with self.cache_time_file.open("w") as file: file.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) def get_price_for_date(self, date_str): diff --git a/src/akkudoktoreos/config.py b/src/akkudoktoreos/config.py index 1604299..c527a05 100644 --- a/src/akkudoktoreos/config.py +++ b/src/akkudoktoreos/config.py @@ -1,30 +1,290 @@ +"""This module provides functionality to manage and handle configuration for the EOS system. + +The module including loading, merging, and validating JSON configuration files. +It also provides utility functions for working directory setup and date handling. + +Key features: +- Loading and merging configurations from default or custom JSON files +- Validating configurations using Pydantic models +- Managing directory setups for the application +- Utility to get prediction start and end dates +""" + +import json +import os +import shutil from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional -output_dir = "output" +from pydantic import BaseModel, ValidationError -prediction_hours = 48 -optimization_hours = 24 -strafe = 10 -possible_ev_charge_currents = [ - 0.0, - 6.0 / 16.0, - # 7.0 / 16.0, - 8.0 / 16.0, - # 9.0 / 16.0, - 10.0 / 16.0, - # 11.0 / 16.0, - 12.0 / 16.0, - # 13.0 / 16.0, - 14.0 / 16.0, - # 15.0 / 16.0, - 1.0, -] +EOS_DIR = "EOS_DIR" +ENCODING = "UTF-8" +CONFIG_FILE_NAME = "EOS.config.json" +DEFAULT_CONFIG_FILE = Path(__file__).parent.joinpath("default.config.json") -def get_start_enddate(prediction_hours=48, startdate=None): - ############ - # Parameter - ############ +class FolderConfig(BaseModel): + """Folder configuration for the EOS system. + + Uses working_dir as root path. + The working directory can be either cwd or + a path or folder defined by the EOS_DIR environment variable. + + Attributes: + output (str): Directory name for output files. + cache (str): Directory name for cache files. + """ + + output: str + cache: str + + +class EOSConfig(BaseModel): + """EOS system-specific configuration. + + Attributes: + prediction_hours (int): Number of hours for predictions. + optimization_hours (int): Number of hours for optimizations. + penalty (int): Penalty factor used in optimization. + available_charging_rates_in_percentage (list[float]): List of available charging rates as percentages. + """ + + prediction_hours: int + optimization_hours: int + penalty: int + available_charging_rates_in_percentage: list[float] + feed_in_tariff_eur_per_wh: int + + +class BaseConfig(BaseModel): + """Base configuration for the EOS system. + + Attributes: + directories (FolderConfig): Configuration for directory paths (output, cache). + eos (EOSConfig): Configuration for EOS-specific settings. + """ + + directories: FolderConfig + eos: EOSConfig + + +class AppConfig(BaseConfig): + """Application-level configuration that extends the base configuration with a working directory. + + Attributes: + working_dir (Path): The root directory for the application. + """ + + working_dir: Path + + def run_setup(self) -> None: + """Runs setup for the application by ensuring that required directories exist. + + If a directory does not exist, it is created. + + Raises: + OSError: If directories cannot be created. + """ + print("Checking directory settings and creating missing directories...") + for key, value in self.directories.model_dump().items(): + if not isinstance(value, str): + continue + path = self.working_dir / value + print(f"'{key}': {path}") + os.makedirs(path, exist_ok=True) + + +class SetupIncomplete(Exception): + """Exception class for errors related to incomplete setup of the EOS system.""" + + +def _load_json(path: Path) -> dict[str, Any]: + """Load a JSON file from a given path. + + Args: + path (Path): Path to the JSON file. + + Returns: + dict[str, Any]: Parsed JSON content. + + Raises: + FileNotFoundError: If the JSON file does not exist. + json.JSONDecodeError: If the file cannot be parsed as valid JSON. + """ + with path.open("r") as f_in: + return json.load(f_in) + + +def _merge_json(default_data: dict[str, Any], custom_data: dict[str, Any]) -> dict[str, Any]: + """Recursively merge two dictionaries, using values from `custom_data` when available. + + Args: + default_data (dict[str, Any]): The default configuration values. + custom_data (dict[str, Any]): The custom configuration values. + + Returns: + dict[str, Any]: Merged configuration data. + """ + merged_data = {} + for key, default_value in default_data.items(): + if key in custom_data: + custom_value = custom_data[key] + if isinstance(default_value, dict) and isinstance(custom_value, dict): + merged_data[key] = _merge_json(default_value, custom_value) + elif type(default_value) is type(custom_value): + merged_data[key] = custom_value + else: + # use default value if types differ + merged_data[key] = default_value + else: + merged_data[key] = default_value + return merged_data + + +def _config_update_available(merged_data: dict[str, Any], custom_data: dict[str, Any]) -> bool: + """Check if the configuration needs to be updated by comparing merged data and custom data. + + Args: + merged_data (dict[str, Any]): The merged configuration data. + custom_data (dict[str, Any]): The custom configuration data. + + Returns: + bool: True if there is a difference indicating that an update is needed, otherwise False. + """ + if merged_data.keys() != custom_data.keys(): + return True + + for key in merged_data: + value1 = merged_data[key] + value2 = custom_data[key] + + if isinstance(value1, dict) and isinstance(value2, dict): + if _config_update_available(value1, value2): + return True + elif value1 != value2: + return True + return False + + +def get_config_file(path: Path, copy_default: bool) -> Path: + """Get the valid configuration file path. If the custom config is not found, it uses the default config. + + Args: + path (Path): Path to the working directory. + copy_default (bool): If True, copy the default configuration if custom config is not found. + + Returns: + Path: Path to the valid configuration file. + """ + config = path.resolve() / CONFIG_FILE_NAME + if config.is_file(): + print(f"Using configuration from: {config}") + return config + + if not path.is_dir(): + print(f"Path does not exist: {path}. Using default configuration...") + return DEFAULT_CONFIG_FILE + + if not copy_default: + print("No custom configuration provided. Using default configuration...") + return DEFAULT_CONFIG_FILE + + try: + return Path(shutil.copy2(DEFAULT_CONFIG_FILE, config)) + except Exception as exc: + print(f"Could not copy default config: {exc}. Using default copy...") + return DEFAULT_CONFIG_FILE + + +def _merge_and_update(custom_config: Path, update_outdated: bool = False) -> bool: + """Merge custom and default configurations, and optionally update the custom config if outdated. + + Args: + custom_config (Path): Path to the custom configuration file. + update_outdated (bool): If True, update the custom config if it is outdated. + + Returns: + bool: True if the custom config was updated, otherwise False. + """ + if custom_config == DEFAULT_CONFIG_FILE: + return False + default_data = _load_json(DEFAULT_CONFIG_FILE) + custom_data = _load_json(custom_config) + merged_data = _merge_json(default_data, custom_data) + + if not _config_update_available(merged_data, custom_data): + print(f"Custom config {custom_config} is up-to-date...") + return False + print(f"Custom config {custom_config} is outdated...") + if update_outdated: + with custom_config.open("w") as f_out: + json.dump(merged_data, f_out, indent=2) + return True + return False + + +def load_config( + working_dir: Path, copy_default: bool = False, update_outdated: bool = True +) -> AppConfig: + """Load the application configuration from the specified directory, merging with defaults if needed. + + Args: + working_dir (Path): Path to the working directory. + copy_default (bool): Whether to copy the default configuration if custom config is missing. + update_outdated (bool): Whether to update outdated custom configuration. + + Returns: + AppConfig: Loaded application configuration. + + Raises: + ValueError: If the configuration is incomplete or not valid. + """ + # make sure working_dir is always a full path + working_dir = working_dir.resolve() + + config = get_config_file(working_dir, copy_default) + _merge_and_update(config, update_outdated) + + with config.open("r", encoding=ENCODING) as f_in: + try: + base_config = BaseConfig.model_validate(json.load(f_in)) + return AppConfig.model_validate( + {"working_dir": working_dir, **base_config.model_dump()} + ) + except ValidationError as exc: + raise ValueError(f"Configuration {config} is incomplete or not valid: {exc}") + + +def get_working_dir() -> Path: + """Get the working directory for the application, either from an environment variable or the current working directory. + + Returns: + Path: The path to the working directory. + """ + custom_dir = os.getenv(EOS_DIR) + if custom_dir is None: + working_dir = Path.cwd() + print(f"No custom directory provided. Setting working directory to: {working_dir}") + else: + working_dir = Path(custom_dir).resolve() + print(f"Custom directory provided. Setting working directory to: {working_dir}") + return working_dir + + +def get_start_enddate( + prediction_hours: int, startdate: Optional[datetime] = None +) -> tuple[str, str]: + """Calculate the start and end dates based on the given prediction hours and optional start date. + + Args: + prediction_hours (int): Number of hours for predictions. + startdate (Optional[datetime]): Optional starting datetime. + + Returns: + tuple[str, str]: The current date (start date) and end date in the format 'YYYY-MM-DD'. + """ if startdate is None: date = (datetime.now().date() + timedelta(hours=prediction_hours)).strftime("%Y-%m-%d") date_now = datetime.now().strftime("%Y-%m-%d") diff --git a/src/akkudoktoreos/default.config.json b/src/akkudoktoreos/default.config.json new file mode 100644 index 0000000..d05c20a --- /dev/null +++ b/src/akkudoktoreos/default.config.json @@ -0,0 +1,15 @@ +{ + "directories": { + "output": "output", + "cache": "cache" + }, + "eos": { + "prediction_hours": 48, + "optimization_hours": 24, + "penalty": 10, + "available_charging_rates_in_percentage": [ + 0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0 + ], + "feed_in_tariff_eur_per_wh": 48 + } +} diff --git a/src/akkudoktoreos/visualize.py b/src/akkudoktoreos/visualize.py index 7e2c547..21ecfcb 100644 --- a/src/akkudoktoreos/visualize.py +++ b/src/akkudoktoreos/visualize.py @@ -1,5 +1,4 @@ import datetime -import os # Set the backend for matplotlib to Agg import matplotlib @@ -8,7 +7,7 @@ import numpy as np from matplotlib.backends.backend_pdf import PdfPages from akkudoktoreos.class_sommerzeit import ist_dst_wechsel -from akkudoktoreos.config import output_dir +from akkudoktoreos.config import AppConfig, SetupIncomplete matplotlib.use("Agg") @@ -23,22 +22,24 @@ def visualisiere_ergebnisse( discharge, # Discharge allowed temperature, start_hour, - prediction_hours, einspeiseverguetung_euro_pro_wh, + config: AppConfig, filename="visualization_results.pdf", extra_data=None, ): ##################### # 24-hour visualization ##################### - if not os.path.exists(output_dir): - os.makedirs(output_dir) - output_file = os.path.join(output_dir, filename) + output_dir = config.working_dir / config.directories.output + if not output_dir.is_dir(): + raise SetupIncomplete(f"Output path does not exist: {output_dir}.") + + output_file = output_dir.joinpath(filename) with PdfPages(output_file) as pdf: # Load and PV generation plt.figure(figsize=(14, 14)) plt.subplot(3, 3, 1) - hours = np.arange(0, prediction_hours) + hours = np.arange(0, config.eos.prediction_hours) gesamtlast_array = np.array(gesamtlast) # Plot individual loads @@ -101,9 +102,9 @@ def visualisiere_ergebnisse( plt.figure(figsize=(14, 10)) if ist_dst_wechsel(datetime.datetime.now()): - hours = np.arange(start_hour, prediction_hours - 1) + hours = np.arange(start_hour, config.eos.prediction_hours - 1) else: - hours = np.arange(start_hour, prediction_hours) + hours = np.arange(start_hour, config.eos.prediction_hours) # Energy flow, grid feed-in, and grid consumption plt.subplot(3, 2, 1) @@ -187,7 +188,7 @@ def visualisiere_ergebnisse( # Plot for AC, DC charging, and Discharge status using bar charts ax1 = plt.subplot(3, 2, 5) - hours = np.arange(0, prediction_hours) + hours = np.arange(0, config.eos.prediction_hours) # Plot AC charging as bars (relative values between 0 and 1) plt.bar(hours, ac, width=0.4, label="AC Charging (relative)", color="blue", alpha=0.6) @@ -209,16 +210,16 @@ def visualisiere_ergebnisse( # Configure the plot ax1.legend(loc="upper left") - ax1.set_xlim(0, prediction_hours) + ax1.set_xlim(0, config.eos.prediction_hours) ax1.set_xlabel("Hour") ax1.set_ylabel("Relative Power (0-1) / Discharge (0 or 1)") ax1.set_title("AC/DC Charging and Discharge Overview") ax1.grid(True) if ist_dst_wechsel(datetime.datetime.now()): - hours = np.arange(start_hour, prediction_hours - 1) + hours = np.arange(start_hour, config.eos.prediction_hours - 1) else: - hours = np.arange(start_hour, prediction_hours) + hours = np.arange(start_hour, config.eos.prediction_hours) pdf.savefig() # Save the current figure state to the PDF plt.close() # Close the current figure to free up memory diff --git a/src/akkudoktoreosserver/flask_server.py b/src/akkudoktoreosserver/flask_server.py index 7062a0e..c59f84f 100755 --- a/src/akkudoktoreosserver/flask_server.py +++ b/src/akkudoktoreosserver/flask_server.py @@ -20,20 +20,18 @@ from akkudoktoreos.class_optimize import optimization_problem from akkudoktoreos.class_pv_forecast import PVForecast from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast from akkudoktoreos.config import ( + SetupIncomplete, get_start_enddate, - optimization_hours, - output_dir, - prediction_hours, + get_working_dir, + load_config, ) app = Flask(__name__) -opt_class = optimization_problem( - prediction_hours=prediction_hours, - strafe=10, - optimization_hours=optimization_hours, - verbose=True, -) +working_dir = get_working_dir() +# copy config to working directory. Make this a CLI option later +config = load_config(working_dir, True) +opt_class = optimization_problem(config) def isfloat(num: Any) -> TypeGuard[float]: @@ -58,14 +56,11 @@ def isfloat(num: Any) -> TypeGuard[float]: @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 + date_now, date = get_start_enddate(config.eos.prediction_hours, startdate=datetime.now().date()) price_forecast = HourlyElectricityPriceForecast( source=f"https://api.akkudoktor.net/prices?start={date_now}&end={date}", - prediction_hours=prediction_hours, - cache=False, + config=config.eos.prediction_hours, + use_cache=False, ) specific_date_prices = price_forecast.get_price_for_daterange( date_now, date @@ -141,7 +136,7 @@ def flask_gesamtlast_simple(): request.args.get("year_energy") ) # Get annual energy value from query parameters date_now, date = get_start_enddate( - prediction_hours, startdate=datetime.now().date() + config.eos.prediction_hours, startdate=datetime.now().date() ) # Get the current date and prediction end date ############### @@ -159,7 +154,9 @@ def flask_gesamtlast_simple(): 0 ] # Get expected household load for the date range - gesamtlast = Gesamtlast(prediction_hours=prediction_hours) # Create Gesamtlast instance + gesamtlast = Gesamtlast( + prediction_hours=config.eos.prediction_hours + ) # Create Gesamtlast instance gesamtlast.hinzufuegen( "Haushalt", leistung_haushalt ) # Add household load to total load calculation @@ -181,13 +178,15 @@ def flask_pvprognose(): # 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()) + date_now, date = get_start_enddate( + config.eos.prediction_hours, startdate=datetime.now().date() + ) ############### # PV Forecast ############### PVforecast = PVForecast( - prediction_hours=prediction_hours, url=url + prediction_hours=config.eos.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( @@ -255,9 +254,10 @@ def flask_optimize(): @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 + output_path = config.working_dir / config.directories.output + if not output_path.is_dir(): + raise SetupIncomplete(f"Output path does not exist: {output_path}.") + return send_from_directory(output_path, "visualization_results.pdf") @app.route("/site-map") @@ -293,6 +293,8 @@ def root(): if __name__ == "__main__": try: + config.run_setup() + # 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) diff --git a/tests/conftest.py b/tests/conftest.py index 04f1c4a..98613db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,21 @@ import os import subprocess import sys import time +from pathlib import Path import pytest from xprocess import ProcessStarter +from akkudoktoreos.config import EOS_DIR, AppConfig, load_config + + +@pytest.fixture(name="tmp_config") +def load_config_tmp(tmp_path: Path) -> AppConfig: + """Creates an AppConfig from default.config.json with a tmp output directory.""" + config = load_config(tmp_path) + config.directories.output = tmp_path + return config + @pytest.fixture(autouse=True) def disable_debug_logging(): @@ -29,7 +40,7 @@ def is_full_run(request): @pytest.fixture -def server(xprocess): +def server(xprocess, tmp_path: Path): """Fixture to start the server. Provides URL of the server. @@ -45,8 +56,7 @@ def server(xprocess): stderr=subprocess.PIPE, ) except subprocess.CalledProcessError: - test_dir = os.path.dirname(os.path.realpath(__file__)) - project_dir = os.path.abspath(os.path.join(test_dir, "..")) + project_dir = Path(__file__).parent.parent subprocess.run( [sys.executable, "-m", "pip", "install", "-e", project_dir], check=True, @@ -56,14 +66,15 @@ def server(xprocess): # command to start server process args = [sys.executable, "-m", "akkudoktoreosserver.flask_server"] + env = {EOS_DIR: f"{tmp_path}", **os.environ.copy()} # startup pattern pattern = "Debugger PIN:" - # search the first 12 lines for the startup pattern, if not found + # search the first 30 lines for the startup pattern, if not found # a RuntimeError will be raised informing the user - max_read_lines = 12 + max_read_lines = 30 - # will wait for 10 seconds before timing out + # will wait for 30 seconds before timing out timeout = 30 # xprocess will now attempt to clean up upon interruptions diff --git a/tests/test_class_ems.py b/tests/test_class_ems.py index a24b66f..a7baede 100644 --- a/tests/test_class_ems.py +++ b/tests/test_class_ems.py @@ -4,7 +4,8 @@ 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_inverter import Wechselrichter +from akkudoktoreos.config import AppConfig prediction_hours = 48 optimization_hours = 24 @@ -13,7 +14,7 @@ start_hour = 1 # Example initialization of necessary components @pytest.fixture -def create_ems_instance(): +def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem: """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) @@ -189,6 +190,7 @@ def create_ems_instance(): # Initialize the energy management system with the respective parameters ems = EnergieManagementSystem( + config=tmp_config.eos, pv_prognose_wh=pv_prognose_wh, strompreis_euro_pro_wh=strompreis_euro_pro_wh, einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, diff --git a/tests/test_class_ems_2.py b/tests/test_class_ems_2.py index e2493ad..9f08165 100644 --- a/tests/test_class_ems_2.py +++ b/tests/test_class_ems_2.py @@ -4,7 +4,8 @@ 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_inverter import Wechselrichter +from akkudoktoreos.config import AppConfig prediction_hours = 48 optimization_hours = 24 @@ -13,7 +14,7 @@ start_hour = 0 # Example initialization of necessary components @pytest.fixture -def create_ems_instance(): +def create_ems_instance(tmp_config: AppConfig) -> EnergieManagementSystem: """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) @@ -96,6 +97,7 @@ def create_ems_instance(): # Initialize the energy management system with the respective parameters ems = EnergieManagementSystem( + config=tmp_config.eos, pv_prognose_wh=pv_prognose_wh, strompreis_euro_pro_wh=strompreis_euro_pro_wh, einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh, diff --git a/tests/test_class_optimize.py b/tests/test_class_optimize.py index f0396d4..cdf8ddb 100644 --- a/tests/test_class_optimize.py +++ b/tests/test_class_optimize.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from akkudoktoreos.class_optimize import optimization_problem +from akkudoktoreos.config import AppConfig DIR_TESTDATA = Path(__file__).parent / "testdata" @@ -33,17 +34,25 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]): ], ) @patch("akkudoktoreos.class_optimize.visualisiere_ergebnisse") -def test_optimize(visualisiere_ergebnisse_patch, fn_in: str, fn_out: str, ngen: int, is_full_run): +def test_optimize( + visualisiere_ergebnisse_patch, + fn_in: str, + fn_out: str, + ngen: int, + is_full_run: bool, + tmp_config: AppConfig, +): + """Test optimierung_ems.""" # Load input and output data - with open(DIR_TESTDATA / fn_in, "r") as f_in: + file = DIR_TESTDATA / fn_in + with file.open("r") as f_in: input_data = json.load(f_in) - with open(DIR_TESTDATA / fn_out, "r") as f_out: + file = DIR_TESTDATA / fn_out + with file.open("r") as f_out: expected_output_data = json.load(f_out) - opt_class = optimization_problem( - prediction_hours=48, strafe=10, optimization_hours=24, fixed_seed=42 - ) + opt_class = optimization_problem(tmp_config, fixed_seed=42) start_hour = 10 if ngen > 10 and not is_full_run: diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..cc2dd41 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,71 @@ +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from akkudoktoreos.config import ( + CONFIG_FILE_NAME, + DEFAULT_CONFIG_FILE, + get_config_file, + load_config, +) + + +def test_config() -> None: + """Test the default config file.""" + try: + load_config(Path.cwd()) + except ValidationError as exc: + pytest.fail(f"Default configuration is not valid: {exc}") + + +def test_config_copy(tmp_path: Path) -> None: + """Test if the config is copied to the provided path.""" + assert DEFAULT_CONFIG_FILE == get_config_file(Path("does", "not", "exist"), False) + + load_config(tmp_path, True) + expected_config = tmp_path.joinpath(CONFIG_FILE_NAME) + + assert expected_config == get_config_file(tmp_path, False) + assert expected_config.is_file() + + +def test_config_merge(tmp_path: Path) -> None: + """Test if config is merged and updated correctly.""" + config_file = tmp_path.joinpath(CONFIG_FILE_NAME) + custom_config = { + "eos": { + "optimization_hours": 30, + "penalty": 21, + "does_not_exist": "nope", + "available_charging_rates_in_percentage": "False entry", + } + } + with config_file.open("w") as f_out: + json.dump(custom_config, f_out) + + assert config_file.exists() + + with pytest.raises(ValueError): + # custom configuration is broken but not updated. + load_config(tmp_path, tmp_path, False) + + with config_file.open("r") as f_in: + # custom configuration is not changed. + assert json.load(f_in) == custom_config + + config = load_config(tmp_path) + + assert config.eos.optimization_hours == 30 + assert config.eos.penalty == 21 + + +def test_setup(tmp_path: Path) -> None: + """Test setup.""" + config = load_config(tmp_path, True) + config.run_setup() + + assert tmp_path.joinpath(CONFIG_FILE_NAME).is_file() + assert tmp_path.joinpath(config.directories.cache).is_dir() + assert tmp_path.joinpath(config.directories.output).is_dir() diff --git a/tests/test_server.py b/tests/test_server.py index fbf027a..5ec4758 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,10 +1,24 @@ +from http import HTTPStatus +from pathlib import Path + import requests -from akkudoktoreos.config import prediction_hours +from akkudoktoreos.config import CONFIG_FILE_NAME, load_config -def test_server(server): +def test_fixture_setup(server, tmp_path: Path) -> None: + """Test if the fixture sets up the server with the env var.""" + # validate correct path in server + config = load_config(tmp_path, False) + assert tmp_path.joinpath(CONFIG_FILE_NAME).is_file() + cache = tmp_path / config.directories.cache + assert cache.is_dir() + + +def test_server(server, tmp_path: Path): """Test the server.""" result = requests.get(f"{server}/gesamtlast_simple?year_energy=2000&") - assert result.status_code == 200 - assert len(result.json()) == prediction_hours + assert result.status_code == HTTPStatus.OK + + config = load_config(tmp_path, False) + assert len(result.json()) == config.eos.prediction_hours diff --git a/tests/test_visualize.py b/tests/test_visualize.py index da6b459..3cbd6d3 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -1,10 +1,10 @@ import json from pathlib import Path -from unittest.mock import patch import pytest from matplotlib.testing.compare import compare_images +from akkudoktoreos.config import AppConfig from akkudoktoreos.visualize import visualisiere_ergebnisse DIR_TESTDATA = Path(__file__).parent / "testdata" @@ -15,14 +15,16 @@ DIR_IMAGEDATA = DIR_TESTDATA / "images" "fn_in, fn_out, fn_out_base", [("visualize_input_1.json", "visualize_output_1.pdf", "visualize_base_output_1.pdf")], ) -@patch("akkudoktoreos.visualize.output_dir", DIR_IMAGEDATA) -def test_visualisiere_ergebnisse(fn_in, fn_out, fn_out_base): +def test_visualisiere_ergebnisse(fn_in, fn_out, fn_out_base, tmp_config: AppConfig): with open(DIR_TESTDATA / fn_in, "r") as f: input_data = json.load(f) - visualisiere_ergebnisse(**input_data) + visualisiere_ergebnisse(config=tmp_config, **input_data) + output_file: Path = tmp_config.working_dir / tmp_config.directories.output / fn_out + + assert output_file.is_file() assert ( compare_images( - str(DIR_IMAGEDATA / fn_out), + str(output_file), str(DIR_IMAGEDATA / fn_out_base), 0, ) diff --git a/tests/testdata/visualize_input_1.json b/tests/testdata/visualize_input_1.json index 318c975..2306b71 100644 --- a/tests/testdata/visualize_input_1.json +++ b/tests/testdata/visualize_input_1.json @@ -1,62 +1,174 @@ { "gesamtlast": [ - 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, 995.0, 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 + 676.71, 876.19, 527.13, 468.88, 531.38, 517.95, 483.15, 472.28, 1011.68, + 995.0, 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.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5000.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0 ], "strompreise": [ - 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.001, 0.00005, 0.00005, 0.00005, 0.00005, 0.001, 0.001, 0.001, 0.001, 0.001, 0.00001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001 + 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, 0.00001, + 0.00001, 0.00001, 0.001, 0.00005, 0.00005, 0.00005, 0.00005, 0.001, 0.001, + 0.001, 0.001, 0.001, 0.00001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, + 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, + 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001 ], "ergebnisse": { "Last_Wh_pro_Stunde": [ - 12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15, 3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 1320.56, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 988.56, 2035.7436363636361, 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 + 12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15, + 3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 1320.56, 1132.03, + 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 988.56, + 2035.7436363636361, 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 ], "Netzeinspeisung_Wh_pro_Stunde": [ - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3679.44, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3679.44, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0 ], "Netzbezug_Wh_pro_Stunde": [ - 12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15, 3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 0.0, 1132.03, 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 0.0, 2035.7436363636361, 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, 0.0, 1221.04, 0.0, 0.0, 0.0, 592.97 + 12493.71, 10502.19, 12775.13, 15356.88, 11468.38, 4037.95, 6047.15, + 3112.2799999999997, 3211.68, 995.0, 1053.07, 1063.91, 0.0, 1132.03, + 1163.67, 1176.82, 1216.22, 1103.78, 1129.12, 1178.71, 1050.98, 0.0, + 2035.7436363636361, 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, 0.0, 1221.04, 0.0, 0.0, 0.0, + 592.97 ], "Kosten_Euro_pro_Stunde": [ - 0.1249371, 0.10502190000000002, 0.1277513, 0.1535688, 0.1146838, 0.0403795, 0.060471500000000004, 0.0311228, 0.0321168, 0.00995, 1.05307, 0.05319550000000001, 0.0, 0.0566015, 0.058183500000000006, 1.17682, 1.21622, 1.10378, 1.12912, 1.1787100000000001, 0.010509800000000001, 0.0, 2.035743636363636, 0.7046100000000001, 0.51637, 0.86805, 0.6943400000000001, 0.6087899999999999, 0.55631, 0.48889, 0.5069100000000001, 0.80489, 1.14198, 1.05697, 0.99246, 1.15599, 0.82701, 1.25798, 1.2326700000000002, 0.87126, 0.86088, 1.15803, 0.0, 1.22104, 0.0, 0.0, 0.0, 0.59297 + 0.1249371, 0.10502190000000002, 0.1277513, 0.1535688, 0.1146838, + 0.0403795, 0.060471500000000004, 0.0311228, 0.0321168, 0.00995, 1.05307, + 0.05319550000000001, 0.0, 0.0566015, 0.058183500000000006, 1.17682, + 1.21622, 1.10378, 1.12912, 1.1787100000000001, 0.010509800000000001, 0.0, + 2.035743636363636, 0.7046100000000001, 0.51637, 0.86805, + 0.6943400000000001, 0.6087899999999999, 0.55631, 0.48889, + 0.5069100000000001, 0.80489, 1.14198, 1.05697, 0.99246, 1.15599, 0.82701, + 1.25798, 1.2326700000000002, 0.87126, 0.86088, 1.15803, 0.0, 1.22104, 0.0, + 0.0, 0.0, 0.59297 ], "akku_soc_pro_stunde": [ - 25.0, 31.666666666666664, 38.333333333333336, 55.00000000000001, 61.66666666666667, 75.0, 81.66666666666667, 91.66666666666666, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 95.7448347107438, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 94.73691460055097, 94.73691460055097, 90.64777031680441, 86.39927685950414, 83.23988464187329, 83.23988464187329 + 25.0, 31.666666666666664, 38.333333333333336, 55.00000000000001, + 61.66666666666667, 75.0, 81.66666666666667, 91.66666666666666, 100.0, + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + 100.0, 100.0, 95.7448347107438, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + 100.0, 100.0, 100.0, 100.0, 94.73691460055097, 94.73691460055097, + 90.64777031680441, 86.39927685950414, 83.23988464187329, 83.23988464187329 ], "Einnahmen_Euro_pro_Stunde": [ - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2575608, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2575608, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0 ], "Gesamtbilanz_Euro": 27.732796636363638, "E-Auto_SoC_pro_Stunde": [ - 30.294999999999998, 43.405, 60.885, 78.365, 93.66, 93.66, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0 + 30.294999999999998, 43.405, 60.885, 78.365, 93.66, 93.66, 100.0, 100.0, + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0 ], "Gesamteinnahmen_Euro": 0.2575608, "Gesamtkosten_Euro": 27.990357436363638, "Verluste_Pro_Stunde": [ - 843.0, 654.0, 792.0, 1152.0, 723.0, 480.0, 440.2105263157896, 360.0, 300.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 134.80363636363631, 153.18595041322305, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 166.73454545454547, 0.0, 129.54409090909098, 134.59227272727276, 100.08954545454549, 0.0 + 843.0, 654.0, 792.0, 1152.0, 723.0, 480.0, 440.2105263157896, 360.0, + 300.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 134.80363636363631, 153.18595041322305, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 166.73454545454547, 0.0, 129.54409090909098, 134.59227272727276, + 100.08954545454549, 0.0 ], "Gesamt_Verluste": 6563.160567638104, "Haushaltsgeraet_wh_pro_stunde": [ - null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null ] }, "ac": [ - 0.6, 0.4, 0.4, 1.0, 0.4, 0.8, 0.4, 0.6, 1.0, 0.2, 0.2, 0.2, 0.6, 0.0, 0.2, 0.6, 0.0, 0.0, 0.8, 0.8, 0.4, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.2, 0.8, 0.2, 0.4, 0.6, 1.0, 0.0, 1.0, 0.8, 0.4, 0.4, 1.0, 0.2, 0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 0.6, 0.4, 0.4, 1.0, 0.4, 0.8, 0.4, 0.6, 1.0, 0.2, 0.2, 0.2, 0.6, 0.0, 0.2, + 0.6, 0.0, 0.0, 0.8, 0.8, 0.4, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.2, 0.8, + 0.2, 0.4, 0.6, 1.0, 0.0, 1.0, 0.8, 0.4, 0.4, 1.0, 0.2, 0.6, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0 ], "dc": [ - 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, 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, 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, 1, 1, 1 ], "discharge": [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0 ], "temperature": [ - 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 + 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 ], "start_hour": 0, - "prediction_hours": 48, "einspeiseverguetung_euro_pro_wh": [ - 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007 + 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, + 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, + 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, + 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, + 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, + 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007, 0.00007 ], "filename": "visualize_output_1.pdf", "extra_data": null