From 33ea0c903f49d9c5e44ee4fe73671a9b022cd900 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 11:53:52 +0200 Subject: [PATCH 1/7] Remove user specific code --- src/akkudoktoreosserver/flask_server.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/akkudoktoreosserver/flask_server.py b/src/akkudoktoreosserver/flask_server.py index 4544a15..21080cd 100755 --- a/src/akkudoktoreosserver/flask_server.py +++ b/src/akkudoktoreosserver/flask_server.py @@ -204,12 +204,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 From 13ae41d3ce2c6cdfa60641ceed9fdf01d3221791 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 12:18:38 +0200 Subject: [PATCH 2/7] Add json config --- requirements.txt | 1 + src/akkudoktoreos/config.py | 174 ++++++++++++++++++++++---- src/akkudoktoreos/default.config.json | 15 +++ tests/test_config.py | 61 +++++++++ 4 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 src/akkudoktoreos/default.config.json create mode 100644 tests/test_config.py 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/src/akkudoktoreos/config.py b/src/akkudoktoreos/config.py index 2e58deb..30b3e76 100644 --- a/src/akkudoktoreos/config.py +++ b/src/akkudoktoreos/config.py @@ -1,27 +1,157 @@ from datetime import datetime, timedelta +import json +import os +from pathlib import Path +import shutil +from typing import Any, Optional +from pydantic import BaseModel, ValidationError -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): + +CUSTOM_DIR = "EOS_USER_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" + + output: Path + cache: Path + + +class EOSConfig(BaseModel): + "EOS dependent config" + + prediction_hours: int + optimization_hours: int + penalty: int + available_charging_rates_in_percentage: list[float] + + +class BaseConfig(BaseModel): + "The base configuration." + + directories: FolderConfig + eos: EOSConfig + + +class AppConfig(BaseConfig): + "The app config." + + working_dir: Path + + +def _load_json(path: Path) -> dict[str, Any]: + 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]: + 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: + 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 config file path." + 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: + 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 AppConfig from provided path or use default.config.json" + 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 necessary paths for app startup." + custom_dir = os.getenv(CUSTOM_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]: ############ # Parameter ############ 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/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2aeef5d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,61 @@ +import json +from pathlib import Path +from pydantic import ValidationError +import pytest +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 From 8b0ba70c2c04c19e8522fd811499ac9adcf4204c Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 16:43:29 +0200 Subject: [PATCH 3/7] Adjust code to new config --- single_test_optimization.py | 11 +++--- src/akkudoktoreos/class_optimize.py | 18 ++++----- src/akkudoktoreos/class_strompreis.py | 52 ++++++++++++++----------- src/akkudoktoreos/config.py | 9 +++-- src/akkudoktoreos/visualize.py | 12 ++++-- src/akkudoktoreosserver/flask_server.py | 41 +++++++++---------- tests/conftest.py | 11 ++++++ tests/test_class_optimize.py | 18 ++++----- tests/test_config.py | 4 +- tests/test_server.py | 7 +++- 10 files changed, 106 insertions(+), 77 deletions(-) diff --git a/single_test_optimization.py b/single_test_optimization.py index 679d0a9..32bffcd 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,10 @@ "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) +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..e70e252 100644 --- a/src/akkudoktoreos/class_strompreis.py +++ b/src/akkudoktoreos/class_strompreis.py @@ -3,10 +3,13 @@ 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 + def repeat_to_shape(array, target_shape): # Check if the array fits the target shape @@ -22,51 +25,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(): + print(f"Creating cache directory: {self._cache_dir}") + os.makedirs(self._cache_dir, exist_ok=True) + 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 30b3e76..6b6920d 100644 --- a/src/akkudoktoreos/config.py +++ b/src/akkudoktoreos/config.py @@ -1,11 +1,11 @@ -from datetime import datetime, timedelta import json import os -from pathlib import Path import shutil +from datetime import datetime, timedelta +from pathlib import Path from typing import Any, Optional -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError CUSTOM_DIR = "EOS_USER_DIR" ENCODING = "UTF-8" @@ -124,6 +124,9 @@ def load_config( working_dir: Path, copy_default: bool = False, update_outdated: bool = True ) -> AppConfig: "Load AppConfig from provided path or use default.config.json" + # 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) diff --git a/src/akkudoktoreos/visualize.py b/src/akkudoktoreos/visualize.py index f5a2d88..c68ca09 100644 --- a/src/akkudoktoreos/visualize.py +++ b/src/akkudoktoreos/visualize.py @@ -8,7 +8,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 matplotlib.use("Agg") @@ -24,15 +24,21 @@ 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): + output_dir = config.working_dir / config.directories.output + if output_dir.exists() and not output_dir.is_dir(): + raise ValueError(f"Provided output path is not a directory: {output_dir}") + if not output_dir.exists(): + print(f"Creating output directory: {output_dir}") os.makedirs(output_dir) - output_file = os.path.join(output_dir, filename) + + 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 21080cd..944f588 100755 --- a/src/akkudoktoreosserver/flask_server.py +++ b/src/akkudoktoreosserver/flask_server.py @@ -18,18 +18,14 @@ from akkudoktoreos.class_optimize import optimization_problem from akkudoktoreos.class_pv_forecast import PVForecast from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast -from akkudoktoreos.config import ( - get_start_enddate, - optimization_hours, - output_dir, - prediction_hours, -) +from akkudoktoreos.config import get_start_enddate, 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 +50,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 +128,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 +146,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 +170,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( @@ -250,9 +246,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 ValueError(f"Output path does not exist: {output_path}") + return send_from_directory(output_path, "visualization_results.pdf") @app.route("/site-map") diff --git a/tests/conftest.py b/tests/conftest.py index 58dfb07..027bbfc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,21 @@ import os import subprocess import sys +from pathlib import Path import pytest from xprocess import ProcessStarter +from akkudoktoreos.config import 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): 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 index 2aeef5d..05cdbcc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,9 @@ import json from pathlib import Path -from pydantic import ValidationError + import pytest +from pydantic import ValidationError + from akkudoktoreos.config import ( CONFIG_FILE_NAME, DEFAULT_CONFIG_FILE, diff --git a/tests/test_server.py b/tests/test_server.py index 4226f02..44109a7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,8 @@ +from pathlib import Path + import requests -from akkudoktoreos.config import prediction_hours +from akkudoktoreos.config import load_config def test_server(server): @@ -9,4 +11,5 @@ def test_server(server): """ result = requests.get(f"{server}/gesamtlast_simple?year_energy=2000&") assert result.status_code == 200 - assert len(result.json()) == prediction_hours + config = load_config(Path.cwd()) + assert len(result.json()) == config.eos.prediction_hours From 54a4edf5651f5f3310a1ffbb9f787213b7dfbb1f Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 16:53:23 +0200 Subject: [PATCH 4/7] Adjust readme --- README.md | 141 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 90 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 9b3c506..93ae86b 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_USER_DIR`. + +- If the directory specified by `EOS_USER_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) From cad52e66eee896a7f65cd38767f754b3fb7c76cf Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 11 Oct 2024 21:48:03 +0200 Subject: [PATCH 5/7] Add setup method - create necessary folders - enforce that folders are created --- .gitignore | 1 + README.md | 4 ++-- docker-compose.yaml | 16 ++++++++-------- single_test_optimization.py | 1 + src/akkudoktoreos/class_strompreis.py | 7 +++---- src/akkudoktoreos/config.py | 25 +++++++++++++++++++++---- src/akkudoktoreos/visualize.py | 10 +++------- src/akkudoktoreosserver/flask_server.py | 11 +++++++++-- tests/conftest.py | 14 +++++++------- tests/test_config.py | 11 +++++++++++ tests/test_server.py | 19 +++++++++++++++---- 11 files changed, 81 insertions(+), 38 deletions(-) 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 93ae86b..9dbfcc2 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ A default configuration file `default.config.json` is provided. This file contai ### Custom Configuration -Users can specify a custom configuration directory by setting the environment variable `EOS_USER_DIR`. +Users can specify a custom configuration directory by setting the environment variable `EOS_DIR`. -- If the directory specified by `EOS_USER_DIR` contains an existing `config.json` file, the application will use this configuration file. +- 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 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/single_test_optimization.py b/single_test_optimization.py index 32bffcd..531f901 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -264,6 +264,7 @@ # 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 diff --git a/src/akkudoktoreos/class_strompreis.py b/src/akkudoktoreos/class_strompreis.py index e70e252..8bc6862 100644 --- a/src/akkudoktoreos/class_strompreis.py +++ b/src/akkudoktoreos/class_strompreis.py @@ -1,6 +1,5 @@ import hashlib import json -import os import zoneinfo from datetime import datetime, timedelta, timezone from pathlib import Path @@ -8,7 +7,7 @@ import numpy as np import requests -from akkudoktoreos.config import AppConfig +from akkudoktoreos.config import AppConfig, SetupIncomplete def repeat_to_shape(array, target_shape): @@ -28,8 +27,8 @@ class HourlyElectricityPriceForecast: 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(): - print(f"Creating cache directory: {self._cache_dir}") - os.makedirs(self._cache_dir, exist_ok=True) + 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 diff --git a/src/akkudoktoreos/config.py b/src/akkudoktoreos/config.py index 6b6920d..f074abc 100644 --- a/src/akkudoktoreos/config.py +++ b/src/akkudoktoreos/config.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ValidationError -CUSTOM_DIR = "EOS_USER_DIR" +EOS_DIR = "EOS_DIR" ENCODING = "UTF-8" CONFIG_FILE_NAME = "EOS.config.json" DEFAULT_CONFIG_FILE = Path(__file__).parent.joinpath("default.config.json") @@ -16,8 +16,8 @@ class FolderConfig(BaseModel): "Folder configuration" - output: Path - cache: Path + output: str + cache: str class EOSConfig(BaseModel): @@ -41,6 +41,23 @@ class AppConfig(BaseConfig): working_dir: Path + def run_setup(self) -> None: + "Run app setup." + 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 + if path.is_dir(): + print(f"'{key}': {path}") + continue + print(f"Creating directory '{key}': {path}") + os.makedirs(path, exist_ok=True) + + +class SetupIncomplete(Exception): + "Class for all setup related exceptions" + def _load_json(path: Path) -> dict[str, Any]: with path.open("r") as f_in: @@ -142,7 +159,7 @@ def load_config( def get_working_dir() -> Path: "Get necessary paths for app startup." - custom_dir = os.getenv(CUSTOM_DIR) + 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}") diff --git a/src/akkudoktoreos/visualize.py b/src/akkudoktoreos/visualize.py index c68ca09..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 AppConfig +from akkudoktoreos.config import AppConfig, SetupIncomplete matplotlib.use("Agg") @@ -32,11 +31,8 @@ def visualisiere_ergebnisse( # 24-hour visualization ##################### output_dir = config.working_dir / config.directories.output - if output_dir.exists() and not output_dir.is_dir(): - raise ValueError(f"Provided output path is not a directory: {output_dir}") - if not output_dir.exists(): - print(f"Creating output directory: {output_dir}") - os.makedirs(output_dir) + 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: diff --git a/src/akkudoktoreosserver/flask_server.py b/src/akkudoktoreosserver/flask_server.py index 944f588..e3518ad 100755 --- a/src/akkudoktoreosserver/flask_server.py +++ b/src/akkudoktoreosserver/flask_server.py @@ -18,7 +18,12 @@ from akkudoktoreos.class_optimize import optimization_problem from akkudoktoreos.class_pv_forecast import PVForecast from akkudoktoreos.class_strompreis import HourlyElectricityPriceForecast -from akkudoktoreos.config import get_start_enddate, get_working_dir, load_config +from akkudoktoreos.config import ( + SetupIncomplete, + get_start_enddate, + get_working_dir, + load_config, +) app = Flask(__name__) @@ -248,7 +253,7 @@ def get_pdf(): # Endpoint to serve the generated PDF with visualization results output_path = config.working_dir / config.directories.output if not output_path.is_dir(): - raise ValueError(f"Output path does not exist: {output_path}") + raise SetupIncomplete(f"Output path does not exist: {output_path}.") return send_from_directory(output_path, "visualization_results.pdf") @@ -285,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 027bbfc..2a486c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import pytest from xprocess import ProcessStarter -from akkudoktoreos.config import AppConfig, load_config +from akkudoktoreos.config import EOS_DIR, AppConfig, load_config @pytest.fixture(name="tmp_config") @@ -18,7 +18,7 @@ def load_config_tmp(tmp_path: Path) -> AppConfig: @pytest.fixture -def server(xprocess): +def server(xprocess, tmp_path: Path): class Starter(ProcessStarter): # assure server to be installed try: @@ -29,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, @@ -40,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_config.py b/tests/test_config.py index 05cdbcc..0a3db7e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -61,3 +61,14 @@ def test_config_merge(tmp_path: Path) -> None: 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 44109a7..3ab24bb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,15 +1,26 @@ +from http import HTTPStatus from pathlib import Path import requests -from akkudoktoreos.config import load_config +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 - config = load_config(Path.cwd()) + assert result.status_code == HTTPStatus.OK + + config = load_config(tmp_path, False) assert len(result.json()) == config.eos.prediction_hours From 854f36ca7f72e3b143c24f0f90cb00e916775bdf Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 12 Oct 2024 07:46:25 +0200 Subject: [PATCH 6/7] Add json to package data --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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] From a42b7ac158a5f396dfbcda050ba708c45c0524ac Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 13 Oct 2024 11:50:08 +0200 Subject: [PATCH 7/7] Review findings --- docs/develop/getting_started.rst | 26 +++++- src/akkudoktoreos/config.py | 155 +++++++++++++++++++++++++++---- 2 files changed, 162 insertions(+), 19 deletions(-) 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/src/akkudoktoreos/config.py b/src/akkudoktoreos/config.py index f074abc..04fceed 100644 --- a/src/akkudoktoreos/config.py +++ b/src/akkudoktoreos/config.py @@ -1,3 +1,15 @@ +""" +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 @@ -14,14 +26,32 @@ class FolderConfig(BaseModel): - "Folder configuration" + """ + 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 dependent config" + """ + 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 @@ -30,41 +60,80 @@ class EOSConfig(BaseModel): class BaseConfig(BaseModel): - "The base configuration." + """ + 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): - "The app config." + """ + 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: - "Run app setup." + """ + 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 - if path.is_dir(): - print(f"'{key}': {path}") - continue - print(f"Creating directory '{key}': {path}") + print(f"'{key}': {path}") os.makedirs(path, exist_ok=True) class SetupIncomplete(Exception): - "Class for all setup related exceptions" + """ + 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: @@ -82,6 +151,16 @@ def _merge_json(default_data: dict[str, Any], custom_data: dict[str, Any]) -> di 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 @@ -98,7 +177,16 @@ def _config_update_available(merged_data: dict[str, Any], custom_data: dict[str, def get_config_file(path: Path, copy_default: bool) -> Path: - "Get the valid config file 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}") @@ -120,6 +208,16 @@ def get_config_file(path: Path, copy_default: bool) -> Path: 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) @@ -140,7 +238,20 @@ def _merge_and_update(custom_config: Path, update_outdated: bool = False) -> boo def load_config( working_dir: Path, copy_default: bool = False, update_outdated: bool = True ) -> AppConfig: - "Load AppConfig from provided path or use default.config.json" + """ + 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() @@ -158,7 +269,12 @@ def load_config( def get_working_dir() -> Path: - "Get necessary paths for app startup." + """ + 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() @@ -172,9 +288,16 @@ def get_working_dir() -> Path: def get_start_enddate( prediction_hours: int, startdate: Optional[datetime] = None ) -> tuple[str, str]: - ############ - # Parameter - ############ + """ + 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")