diff --git a/.gitignore b/.gitignore index 2b43d60..6b238e6 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 9b3c506..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 @@ -56,6 +74,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 @@ -64,7 +83,6 @@ source .venv/bin/activate ## Usage -Adjust `config.py`. To use the system, run `flask_server.py`, which starts the server: ```bash @@ -93,7 +111,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) Describes the structure and data types of the JSON object sent to the Flask server, with a forecast period of 48 hours. @@ -101,75 +118,88 @@ Describes the structure and data types of the JSON object sent to the Flask serv ## 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. @@ -179,9 +209,11 @@ This document describes the structure and data types of the JSON output returned ## 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). @@ -210,12 +242,15 @@ This object contains information related to the electric vehicle and its chargin - **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. @@ -264,57 +299,61 @@ This object contains the results of the simulation and provides insights into va - **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 + +- **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) diff --git a/docker-compose.yaml b/docker-compose.yaml index cdc5ad6..b3b6812 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 + - ./src/akkudoktoreos/default.config.json:/opt/eos/EOS.config.json:ro ports: - - '${EOS_PORT}:${EOS_PORT}' + - "${EOS_PORT}:${EOS_PORT}" diff --git a/docs/develop/getting_started.rst b/docs/develop/getting_started.rst index 8256c51..3cf6f27 100644 --- a/docs/develop/getting_started.rst +++ b/docs/develop/getting_started.rst @@ -28,7 +28,6 @@ On MacOS (requires `Homebrew `_): 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 @@ -72,11 +71,32 @@ If `pip install` fails to install the mariadb dependency, the following commands Followed by a renewed `pip install -r requirements.txt`. +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. + Usage ***** -Adjust `config.py`. - To start the server: ```bash diff --git a/pyproject.toml b/pyproject.toml index 0256f06..ae7a96f 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.isort] diff --git a/requirements.txt b/requirements.txt index fd82718..1a72b24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,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/single_test_optimization.py b/single_test_optimization.py index 679d0a9..531f901 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 - import json -# Import necessary modules from the project from akkudoktoreos.class_optimize import optimization_problem +from akkudoktoreos.config import get_working_dir, load_config start_hour = 10 @@ -262,10 +261,11 @@ "min_soc_prozent": 15, } -# 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) +config.run_setup() +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) diff --git a/src/akkudoktoreos/class_optimize.py b/src/akkudoktoreos/class_optimize.py index 64715cc..fcc1b7f 100644 --- a/src/akkudoktoreos/class_optimize.py +++ b/src/akkudoktoreos/class_optimize.py @@ -8,25 +8,24 @@ from akkudoktoreos.class_ems import EnergieManagementSystem from akkudoktoreos.class_haushaltsgeraet import Haushaltsgeraet from akkudoktoreos.class_inverter import Wechselrichter -from akkudoktoreos.config import moegliche_ladestroeme_in_prozent +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 = moegliche_ladestroeme_in_prozent + 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 @@ -137,7 +136,7 @@ def evaluate( gesamtbilanz = o["Gesamtbilanz_Euro"] * (-1.0 if worst_case else 1.0) discharge_hours_bin, eautocharge_hours_float, _ = self.split_individual(individual) - max_ladeleistung = np.max(moegliche_ladestroeme_in_prozent) + max_ladeleistung = np.max(self.possible_charge_values) # Penalty for not discharging gesamtbilanz += sum( @@ -308,6 +307,7 @@ def optimierung_ems( start_hour, self.prediction_hours, einspeiseverguetung_euro_pro_wh, + self._config, extra_data=extra_data, ) diff --git a/src/akkudoktoreos/class_strompreis.py b/src/akkudoktoreos/class_strompreis.py index 7f0b1a8..8bc6862 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 @@ -22,51 +24,56 @@ def repeat_to_shape(array, target_shape): class HourlyElectricityPriceForecast: - def __init__(self, source, cache_dir="cache", charges=0.000228, prediction_hours=24): # 228 - self.cache_dir = cache_dir - os.makedirs(self.cache_dir, exist_ok=True) - self.cache_time_file = os.path.join(self.cache_dir, "cache_timestamp.txt") - self.prices = self.load_data(source) + def __init__(self, source: str | Path, config: AppConfig, charges=0.000228): # 228 + self._cache_dir = config.working_dir / config.directories.cache + if not self._cache_dir.is_dir(): + raise SetupIncomplete(f"Cache 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: str | Path): + cache_file = self._get_cache_file(source) - 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(): + if isinstance(source, str): + if cache_file.is_file() and not self._is_cache_expired(): 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() + 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): + def _is_cache_expired(self): + 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: + def _update_cache_timestamp(self): + with self._cache_time_file.open("w") as file: file.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) def get_price_for_date(self, date_str): diff --git a/src/akkudoktoreos/config.py b/src/akkudoktoreos/config.py index 2e58deb..04fceed 100644 --- a/src/akkudoktoreos/config.py +++ b/src/akkudoktoreos/config.py @@ -1,30 +1,303 @@ +""" +This module provides functionality to manage and handle configuration for the EOS system, +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 + +from pydantic import BaseModel, ValidationError + +EOS_DIR = "EOS_DIR" +ENCODING = "UTF-8" +CONFIG_FILE_NAME = "EOS.config.json" +DEFAULT_CONFIG_FILE = Path(__file__).parent.joinpath("default.config.json") + + +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] + + +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. -output_dir = "output" - -prediction_hours = 48 -optimization_hours = 24 -strafe = 10 -moegliche_ladestroeme_in_prozent = [ - 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, -] - - -def get_start_enddate(prediction_hours=48, startdate=None): - ############ - # Parameter - ############ + 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..6a2543d --- /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.4375, 0.5, 0.5625, 0.625, 0.6875, 0.75, 0.8125, 0.875, + 0.9375, 1.0 + ] + } +} diff --git a/src/akkudoktoreos/visualize.py b/src/akkudoktoreos/visualize.py index f5a2d88..b19b256 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 @@ 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") @@ -24,15 +23,18 @@ def visualisiere_ergebnisse( 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)) diff --git a/src/akkudoktoreosserver/flask_server.py b/src/akkudoktoreosserver/flask_server.py index 4544a15..e3518ad 100755 --- a/src/akkudoktoreosserver/flask_server.py +++ b/src/akkudoktoreosserver/flask_server.py @@ -19,17 +19,18 @@ 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 -) +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]: @@ -54,13 +55,9 @@ 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, + f"https://api.akkudoktor.net/prices?start={date_now}&end={date}", config ) specific_date_prices = price_forecast.get_price_for_daterange( date_now, date @@ -136,7 +133,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 ############### @@ -154,7 +151,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 @@ -176,13 +175,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( @@ -204,12 +205,6 @@ def flask_pvprognose(): @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 @@ -256,9 +251,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") @@ -294,6 +290,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 58dfb07..2a486c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,24 @@ import os import subprocess import sys +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 -def server(xprocess): +def server(xprocess, tmp_path: Path): class Starter(ProcessStarter): # assure server to be installed try: @@ -18,8 +29,7 @@ class Starter(ProcessStarter): 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, @@ -29,15 +39,16 @@ class Starter(ProcessStarter): # 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 - timeout = 30 + timeout = 20 # xprocess will now attempt to clean up upon interruptions terminate_on_interrupt = True diff --git a/tests/test_class_optimize.py b/tests/test_class_optimize.py index b88215d..0db6313 100644 --- a/tests/test_class_optimize.py +++ b/tests/test_class_optimize.py @@ -4,23 +4,23 @@ import pytest from akkudoktoreos.class_optimize import optimization_problem -from akkudoktoreos.config import output_dir +from akkudoktoreos.config import AppConfig DIR_TESTDATA = Path(__file__).parent / "testdata" @pytest.mark.parametrize("fn_in, fn_out", [("optimize_input_1.json", "optimize_result_1.json")]) -def test_optimize(fn_in, fn_out): - # Load input and output data - with open(DIR_TESTDATA / fn_in, "r") as f_in: +def test_optimize(fn_in: str, fn_out: str, tmp_config: AppConfig) -> None: + "Test optimierung_ems" + 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 # Call the optimization function @@ -32,5 +32,5 @@ def test_optimize(fn_in, fn_out): assert set(ergebnis) == set(expected_output_data) # The function creates a visualization result PDF as a side-effect. - fp_viz = Path(output_dir) / "visualization_results.pdf" + fp_viz = tmp_config.directories.output / "visualization_results.pdf" assert fp_viz.exists() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0a3db7e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,74 @@ +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 4226f02..3ab24bb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,12 +1,26 @@ +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_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): + +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