diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5f2254a..8b679b7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,7 +26,7 @@ jobs: - name: Run Pytest run: | pip install -e . - python -m pytest --full-run -vs --cov src --cov-report term-missing + python -m pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing - name: Upload test artifacts uses: actions/upload-artifact@v4 diff --git a/Dockerfile b/Dockerfile index d9ee8e5..28e55db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ ENV MPLCONFIGDIR="/tmp/mplconfigdir" ENV EOS_DIR="/opt/eos" ENV EOS_CACHE_DIR="${EOS_DIR}/cache" ENV EOS_OUTPUT_DIR="${EOS_DIR}/output" +ENV EOS_CONFIG_DIR="${EOS_DIR}/config" WORKDIR ${EOS_DIR} @@ -18,7 +19,9 @@ RUN adduser --system --group --no-create-home eos \ && mkdir -p "${EOS_CACHE_DIR}" \ && chown eos "${EOS_CACHE_DIR}" \ && mkdir -p "${EOS_OUTPUT_DIR}" \ - && chown eos "${EOS_OUTPUT_DIR}" + && chown eos "${EOS_OUTPUT_DIR}" \ + && mkdir -p "${EOS_CONFIG_DIR}" \ + && chown eos "${EOS_CONFIG_DIR}" COPY requirements.txt . @@ -34,4 +37,4 @@ EXPOSE 8503 CMD ["python", "-m", "akkudoktoreos.server.fastapi_server"] -VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}"] +VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"] diff --git a/docker-compose.yaml b/docker-compose.yaml index bc340e0..26a0c97 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,7 +11,11 @@ services: dockerfile: "Dockerfile" args: PYTHON_VERSION: "${PYTHON_VERSION}" - volumes: - - ./src/akkudoktoreos/default.config.json:/opt/eos/EOS.config.json:ro + environment: + - EOS_CONFIG_DIR=config + - latitude=52.2 + - longitude=13.4 + - elecprice_provider=ElecPriceAkkudoktor + - elecprice_charges=0.21 ports: - "${EOS_PORT}:${EOS_PORT}" diff --git a/docs/akkudoktoreos/openapi.json b/docs/akkudoktoreos/openapi.json index 7213956..758858c 100644 --- a/docs/akkudoktoreos/openapi.json +++ b/docs/akkudoktoreos/openapi.json @@ -778,26 +778,8 @@ }, "/pvforecast": { "get": { - "summary": "Fastapi Pvprognose", - "operationId": "fastapi_pvprognose_pvforecast_get", - "parameters": [ - { - "name": "ac_power_measurement", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Ac Power Measurement" - } - } - ], + "summary": "Fastapi Pvforecast", + "operationId": "fastapi_pvforecast_pvforecast_get", "responses": { "200": { "description": "Successful Response", @@ -808,16 +790,6 @@ } } } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } } @@ -2987,32 +2959,6 @@ "description": "Sub-path for the EOS cache data directory.", "default": "cache" }, - "config_folder_path": { - "anyOf": [ - { - "type": "string", - "format": "path" - }, - { - "type": "null" - } - ], - "title": "Config Folder Path", - "description": "Path to EOS configuration directory." - }, - "config_file_path": { - "anyOf": [ - { - "type": "string", - "format": "path" - }, - { - "type": "null" - } - ], - "title": "Config File Path", - "description": "Path to EOS configuration file." - }, "pvforecast_planes": { "items": { "type": "string" @@ -3100,6 +3046,34 @@ "description": "Compute data_cache_path based on data_folder_path.", "readOnly": true }, + "config_folder_path": { + "anyOf": [ + { + "type": "string", + "format": "path" + }, + { + "type": "null" + } + ], + "title": "Config Folder Path", + "description": "Path to EOS configuration directory.", + "readOnly": true + }, + "config_file_path": { + "anyOf": [ + { + "type": "string", + "format": "path" + }, + { + "type": "null" + } + ], + "title": "Config File Path", + "description": "Path to EOS configuration file.", + "readOnly": true + }, "config_default_file_path": { "type": "string", "format": "path", @@ -3128,11 +3102,13 @@ "timezone", "data_output_path", "data_cache_path", + "config_folder_path", + "config_file_path", "config_default_file_path", "config_keys" ], "title": "ConfigEOS", - "description": "Singleton configuration handler for the EOS application.\n\nConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic\ninitialization.\n\n`ConfigEOS` ensures that only one instance of the class is created throughout the application,\nallowing consistent access to EOS configuration settings. This singleton instance loads\nconfiguration data from a predefined set of directories or creates a default configuration if\nnone is found.\n\nInitialization Process:\n - Upon instantiation, the singleton instance attempts to load a configuration file in this order:\n 1. The directory specified by the `EOS_DIR` environment variable.\n 2. A platform specific default directory for EOS.\n 3. The current working directory.\n - The first available configuration file found in these directories is loaded.\n - If no configuration file is found, a default configuration file is created in the platform\n specific default directory, and default settings are loaded into it.\n\nAttributes from the loaded configuration are accessible directly as instance attributes of\n`ConfigEOS`, providing a centralized, shared configuration object for EOS.\n\nSingleton Behavior:\n - This class uses the `SingletonMixin` to ensure that all requests for `ConfigEOS` return\n the same instance, which contains the most up-to-date configuration. Modifying the configuration\n in one part of the application reflects across all references to this class.\n\nAttributes:\n _settings (ClassVar[SettingsEOS]): Holds application-wide settings.\n _file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.\n config_folder_path (Optional[Path]): Path to the configuration directory.\n config_file_path (Optional[Path]): Path to the configuration file.\n\nRaises:\n FileNotFoundError: If no configuration file is found, and creating a default configuration fails.\n\nExample:\n To initialize and access configuration attributes (only one instance is created):\n ```python\n config_eos = ConfigEOS() # Always returns the same instance\n print(config_eos.prediction_hours) # Access a setting from the loaded configuration\n ```" + "description": "Singleton configuration handler for the EOS application.\n\nConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic\ninitialization.\n\n`ConfigEOS` ensures that only one instance of the class is created throughout the application,\nallowing consistent access to EOS configuration settings. This singleton instance loads\nconfiguration data from a predefined set of directories or creates a default configuration if\nnone is found.\n\nInitialization Process:\n - Upon instantiation, the singleton instance attempts to load a configuration file in this order:\n 1. The directory specified by the `EOS_CONFIG_DIR` environment variable\n 2. The directory specified by the `EOS_DIR` environment variable.\n 3. A platform specific default directory for EOS.\n 4. The current working directory.\n - The first available configuration file found in these directories is loaded.\n - If no configuration file is found, a default configuration file is created in the platform\n specific default directory, and default settings are loaded into it.\n\nAttributes from the loaded configuration are accessible directly as instance attributes of\n`ConfigEOS`, providing a centralized, shared configuration object for EOS.\n\nSingleton Behavior:\n - This class uses the `SingletonMixin` to ensure that all requests for `ConfigEOS` return\n the same instance, which contains the most up-to-date configuration. Modifying the configuration\n in one part of the application reflects across all references to this class.\n\nAttributes:\n _settings (ClassVar[SettingsEOS]): Holds application-wide settings.\n _file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.\n config_folder_path (Optional[Path]): Path to the configuration directory.\n config_file_path (Optional[Path]): Path to the configuration file.\n\nRaises:\n FileNotFoundError: If no configuration file is found, and creating a default configuration fails.\n\nExample:\n To initialize and access configuration attributes (only one instance is created):\n ```python\n config_eos = ConfigEOS() # Always returns the same instance\n print(config_eos.prediction_hours) # Access a setting from the loaded configuration\n ```" }, "ElectricVehicleParameters": { "properties": { diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index 6800e80..40f967b 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -14,7 +14,7 @@ import shutil from pathlib import Path from typing import Any, ClassVar, List, Optional -import platformdirs +from platformdirs import user_config_dir, user_data_dir from pydantic import Field, ValidationError, computed_field # settings @@ -40,6 +40,24 @@ from akkudoktoreos.utils.utils import UtilsCommonSettings logger = get_logger(__name__) +def get_absolute_path( + basepath: Optional[Path | str], subpath: Optional[Path | str] +) -> Optional[Path]: + """Get path based on base path.""" + if isinstance(basepath, str): + basepath = Path(basepath) + if subpath is None: + return basepath + + if isinstance(subpath, str): + subpath = Path(subpath) + if subpath.is_absolute(): + return subpath + if basepath is not None: + return basepath.joinpath(subpath) + return None + + class ConfigCommonSettings(SettingsBaseModel): """Settings for common configuration.""" @@ -60,22 +78,14 @@ class ConfigCommonSettings(SettingsBaseModel): @property def data_output_path(self) -> Optional[Path]: """Compute data_output_path based on data_folder_path.""" - if self.data_output_subpath is None: - return self.data_folder_path - if self.data_folder_path and self.data_output_subpath: - return self.data_folder_path.joinpath(self.data_output_subpath) - return None + return get_absolute_path(self.data_folder_path, self.data_output_subpath) # Computed fields @computed_field # type: ignore[prop-decorator] @property def data_cache_path(self) -> Optional[Path]: """Compute data_cache_path based on data_folder_path.""" - if self.data_cache_subpath is None: - return self.data_folder_path - if self.data_folder_path and self.data_cache_subpath: - return self.data_folder_path.joinpath(self.data_cache_subpath) - return None + return get_absolute_path(self.data_folder_path, self.data_cache_subpath) class SettingsEOS( @@ -114,9 +124,10 @@ class ConfigEOS(SingletonMixin, SettingsEOS): Initialization Process: - Upon instantiation, the singleton instance attempts to load a configuration file in this order: - 1. The directory specified by the `EOS_DIR` environment variable. - 2. A platform specific default directory for EOS. - 3. The current working directory. + 1. The directory specified by the `EOS_CONFIG_DIR` environment variable + 2. The directory specified by the `EOS_DIR` environment variable. + 3. A platform specific default directory for EOS. + 4. The current working directory. - The first available configuration file found in these directories is loaded. - If no configuration file is found, a default configuration file is created in the platform specific default directory, and default settings are loaded into it. @@ -150,21 +161,29 @@ class ConfigEOS(SingletonMixin, SettingsEOS): APP_NAME: ClassVar[str] = "net.akkudoktor.eos" # reverse order APP_AUTHOR: ClassVar[str] = "akkudoktor" EOS_DIR: ClassVar[str] = "EOS_DIR" + EOS_CONFIG_DIR: ClassVar[str] = "EOS_CONFIG_DIR" ENCODING: ClassVar[str] = "UTF-8" CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json" _settings: ClassVar[Optional[SettingsEOS]] = None _file_settings: ClassVar[Optional[SettingsEOS]] = None - config_folder_path: Optional[Path] = Field( - None, description="Path to EOS configuration directory." - ) - - config_file_path: Optional[Path] = Field( - default=None, description="Path to EOS configuration file." - ) + _config_folder_path: Optional[Path] = None + _config_file_path: Optional[Path] = None # Computed fields + @computed_field # type: ignore[prop-decorator] + @property + def config_folder_path(self) -> Optional[Path]: + """Path to EOS configuration directory.""" + return self._config_folder_path + + @computed_field # type: ignore[prop-decorator] + @property + def config_file_path(self) -> Optional[Path]: + """Path to EOS configuration file.""" + return self._config_file_path + @computed_field # type: ignore[prop-decorator] @property def config_default_file_path(self) -> Path: @@ -297,7 +316,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS): pass # From platform specific default path try: - data_dir = platformdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR) + data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR)) if data_dir is not None: data_dir.mkdir(parents=True, exist_ok=True) self.data_folder_path = data_dir @@ -308,47 +327,27 @@ class ConfigEOS(SingletonMixin, SettingsEOS): data_dir = Path.cwd() self.data_folder_path = data_dir - def _config_folder_path(self) -> Optional[Path]: - """Finds the first directory containing a valid configuration file. + def _get_config_file_path(self) -> tuple[Path, bool]: + """Finds the a valid configuration file or returns the desired path for a new config file. Returns: - Path: The path to the configuration directory, or None if not found. + tuple[Path, bool]: The path to the configuration directory and if there is already a config file there """ config_dirs = [] - config_dir = None - env_dir = os.getenv(self.EOS_DIR) - logger.debug(f"Envionment '{self.EOS_DIR}': '{env_dir}'") + env_base_dir = os.getenv(self.EOS_DIR) + env_config_dir = os.getenv(self.EOS_CONFIG_DIR) + env_dir = get_absolute_path(env_base_dir, env_config_dir) + logger.debug(f"Envionment config dir: '{env_dir}'") if env_dir is not None: - config_dirs.append(Path(env_dir).resolve()) - config_dirs.append(Path(platformdirs.user_config_dir(self.APP_NAME))) + config_dirs.append(env_dir.resolve()) + config_dirs.append(Path(user_config_dir(self.APP_NAME))) config_dirs.append(Path.cwd()) for cdir in config_dirs: cfile = cdir.joinpath(self.CONFIG_FILE_NAME) if cfile.exists(): logger.debug(f"Found config file: '{cfile}'") - config_dir = cdir - break - return config_dir - - def _config_file_path(self) -> Path: - """Finds the path to the configuration file. - - Returns: - Path: The path to the configuration file. May not exist. - """ - config_file = None - config_dir = self._config_folder_path() - if config_dir is None: - # There is currently no configuration file - create it in default path - env_dir = os.getenv(self.EOS_DIR) - if env_dir is not None: - config_dir = Path(env_dir).resolve() - else: - config_dir = Path(platformdirs.user_config_dir(self.APP_NAME)) - config_file = config_dir.joinpath(self.CONFIG_FILE_NAME) - else: - config_file = config_dir.joinpath(self.CONFIG_FILE_NAME) - return config_file + return cfile, True + return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False def from_config_file(self) -> None: """Loads the configuration file settings for EOS. @@ -356,14 +355,14 @@ class ConfigEOS(SingletonMixin, SettingsEOS): Raises: ValueError: If the configuration file is invalid or incomplete. """ - config_file = self._config_file_path() + config_file, exists = self._get_config_file_path() config_dir = config_file.parent - if not config_file.exists(): + if not exists: config_dir.mkdir(parents=True, exist_ok=True) try: shutil.copy2(self.config_default_file_path, config_file) except Exception as exc: - logger.warning(f"Could not copy default config: {exc}. Using default copy...") + logger.warning(f"Could not copy default config: {exc}. Using default config...") config_file = self.config_default_file_path config_dir = config_file.parent @@ -376,8 +375,8 @@ class ConfigEOS(SingletonMixin, SettingsEOS): self.update() # Everthing worked, remember the values - self.config_folder_path = config_dir - self.config_file_path = config_file + self._config_folder_path = config_dir + self._config_file_path = config_file def to_config_file(self) -> None: """Saves the current configuration to the configuration file. diff --git a/src/akkudoktoreos/data/default.config.json b/src/akkudoktoreos/data/default.config.json index 2fc9a73..0659642 100644 --- a/src/akkudoktoreos/data/default.config.json +++ b/src/akkudoktoreos/data/default.config.json @@ -6,15 +6,16 @@ "data_folder_path": null, "data_output_path": null, "data_output_subpath": null, + "elecprice_charges": 0.21, "elecprice_provider": null, "elecpriceimport_file_path": null, - "latitude": null, + "latitude": 52.5, "load_import_file_path": null, "load_name": null, "load_provider": null, "loadakkudoktor_year_energy": null, - "longitude": null, - "optimization_ev_available_charge_rates_percent": [], + "longitude": 13.4, + "optimization_ev_available_charge_rates_percent": null, "optimization_hours": 48, "optimization_penalty": null, "prediction_historic_hours": 48, diff --git a/src/akkudoktoreos/optimization/genetic.py b/src/akkudoktoreos/optimization/genetic.py index c0d3b1d..a05f849 100644 --- a/src/akkudoktoreos/optimization/genetic.py +++ b/src/akkudoktoreos/optimization/genetic.py @@ -123,7 +123,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Decode the input array into ac_charge, dc_charge, and discharge arrays.""" discharge_hours_bin_np = np.array(discharge_hours_bin) - len_ac = len(self.config.optimization_ev_available_charge_rates_percent) + len_ac = len(self.possible_charge_values) # Categorization: # Idle: 0 .. len_ac-1 @@ -155,9 +155,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi discharge[discharge_mask] = 1 # Set Discharge states to 1 ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float) - ac_charge[ac_mask] = [ - self.config.optimization_ev_available_charge_rates_percent[i] for i in ac_indices - ] + ac_charge[ac_mask] = [self.possible_charge_values[i] for i in ac_indices] # Idle is just 0, already default. @@ -166,7 +164,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi def mutate(self, individual: list[int]) -> tuple[list[int]]: """Custom mutation function for the individual.""" # Calculate the number of states - len_ac = len(self.config.optimization_ev_available_charge_rates_percent) + len_ac = len(self.possible_charge_values) if self.optimize_dc_charge: total_states = 3 * len_ac + 2 else: @@ -300,7 +298,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi creator.create("Individual", list, fitness=creator.FitnessMin) self.toolbox = base.Toolbox() - len_ac = len(self.config.optimization_ev_available_charge_rates_percent) + len_ac = len(self.possible_charge_values) # Total number of states without DC: # Idle: len_ac states @@ -378,10 +376,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi if eautocharge_hours_index is not None: eautocharge_hours_float = np.array( - [ - self.config.optimization_ev_available_charge_rates_percent[i] - for i in eautocharge_hours_index - ], + [self.possible_charge_values[i] for i in eautocharge_hours_index], float, ) self.ems.set_ev_charge_hours(eautocharge_hours_float) @@ -615,10 +610,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi start_solution ) eautocharge_hours_float = ( - [ - self.config.optimization_ev_available_charge_rates_percent[i] - for i in eautocharge_hours_index - ] + [self.possible_charge_values[i] for i in eautocharge_hours_index] if eautocharge_hours_index is not None else None ) diff --git a/src/akkudoktoreos/server/fastapi_server.py b/src/akkudoktoreos/server/fastapi_server.py index 4eabd5d..32f1e39 100755 --- a/src/akkudoktoreos/server/fastapi_server.py +++ b/src/akkudoktoreos/server/fastapi_server.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union import httpx +import pandas as pd import uvicorn from fastapi import FastAPI, Query, Request from fastapi.exceptions import HTTPException @@ -412,20 +413,37 @@ class ForecastResponse(PydanticBaseModel): @app.get("/pvforecast") -def fastapi_pvprognose(ac_power_measurement: Optional[float] = None) -> ForecastResponse: +def fastapi_pvforecast() -> ForecastResponse: ############### # PV Forecast ############### - pvforecast_ac_power = prediction_eos["pvforecast_ac_power"] - # Fetch prices for the specified date range - pvforecast_ac_power = pvforecast_ac_power.loc[ - prediction_eos.start_datetime : prediction_eos.end_datetime - ] - pvforecastakkudoktor_temp_air = prediction_eos["pvforecastakkudoktor_temp_air"] - # Fetch prices for the specified date range - pvforecastakkudoktor_temp_air = pvforecastakkudoktor_temp_air.loc[ - prediction_eos.start_datetime : prediction_eos.end_datetime - ] + prediction_key = "pvforecast_ac_power" + pvforecast_ac_power = prediction_eos.get(prediction_key) + if pvforecast_ac_power is None: + raise HTTPException(status_code=404, detail=f"Prediction not available: {prediction_key}") + + # On empty Series.loc TypeError: Cannot compare tz-naive and tz-aware datetime-like objects + if len(pvforecast_ac_power) == 0: + pvforecast_ac_power = pd.Series() + else: + # Fetch prices for the specified date range + pvforecast_ac_power = pvforecast_ac_power.loc[ + prediction_eos.start_datetime : prediction_eos.end_datetime + ] + + prediction_key = "pvforecastakkudoktor_temp_air" + pvforecastakkudoktor_temp_air = prediction_eos.get(prediction_key) + if pvforecastakkudoktor_temp_air is None: + raise HTTPException(status_code=404, detail=f"Prediction not available: {prediction_key}") + + # On empty Series.loc TypeError: Cannot compare tz-naive and tz-aware datetime-like objects + if len(pvforecastakkudoktor_temp_air) == 0: + pvforecastakkudoktor_temp_air = pd.Series() + else: + # Fetch prices for the specified date range + pvforecastakkudoktor_temp_air = pvforecastakkudoktor_temp_air.loc[ + prediction_eos.start_datetime : prediction_eos.end_datetime + ] # Return both forecasts as a JSON response return ForecastResponse( @@ -457,8 +475,8 @@ def fastapi_optimize( def get_pdf() -> PdfResponse: # Endpoint to serve the generated PDF with visualization results output_path = config_eos.data_output_path - if not output_path.is_dir(): - raise ValueError(f"Output path does not exist: {output_path}.") + if output_path is None or not output_path.is_dir(): + raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.") file_path = output_path / "visualization_results.pdf" if not file_path.is_file(): raise HTTPException(status_code=404, detail="No visualization result available.") diff --git a/src/akkudoktoreos/utils/cacheutil.py b/src/akkudoktoreos/utils/cacheutil.py index 2b1e9ba..67a9938 100644 --- a/src/akkudoktoreos/utils/cacheutil.py +++ b/src/akkudoktoreos/utils/cacheutil.py @@ -47,6 +47,7 @@ from typing import ( from pendulum import DateTime, Duration from pydantic import BaseModel, ConfigDict, Field +from akkudoktoreos.core.coreabc import ConfigMixin from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration from akkudoktoreos.utils.logutil import get_logger @@ -90,7 +91,7 @@ class CacheFileStoreMeta(type, Generic[T]): return cls._instances[cls] -class CacheFileStore(metaclass=CacheFileStoreMeta): +class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta): """A key-value store that manages file-like tempfile objects to be used as cache files. Cache files are associated with a date. If no date is specified, the cache files are @@ -328,8 +329,9 @@ class CacheFileStore(metaclass=CacheFileStoreMeta): # File already available cache_file_obj = cache_item.cache_file else: + self.config.data_cache_path.mkdir(parents=True, exist_ok=True) cache_file_obj = tempfile.NamedTemporaryFile( - mode=mode, delete=delete, suffix=suffix + mode=mode, delete=delete, suffix=suffix, dir=self.config.data_cache_path ) self._store[cache_file_key] = CacheFileRecord( cache_file=cache_file_obj, diff --git a/src/akkudoktoreos/utils/logutil.py b/src/akkudoktoreos/utils/logutil.py index a2b471c..39205ba 100644 --- a/src/akkudoktoreos/utils/logutil.py +++ b/src/akkudoktoreos/utils/logutil.py @@ -50,6 +50,8 @@ def get_logger( # Create a logger with the specified name logger = logging.getLogger(name) logger.propagate = True + if (env_level := os.getenv("EOS_LOGGING_LEVEL")) is not None: + logging_level = env_level if logging_level == "DEBUG": level = logging.DEBUG elif logging_level == "INFO": diff --git a/tests/conftest.py b/tests/conftest.py index 1a2562f..1a0b4b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,17 @@ import logging -import shutil +import os import subprocess import sys import tempfile from pathlib import Path from typing import Optional +from unittest.mock import PropertyMock, patch import pendulum -import platformdirs import pytest from xprocess import ProcessStarter -from akkudoktoreos.config.config import get_config +from akkudoktoreos.config.config import ConfigEOS, get_config from akkudoktoreos.utils.logutil import get_logger logger = get_logger(__name__) @@ -42,6 +42,12 @@ def pytest_addoption(parser): parser.addoption( "--full-run", action="store_true", default=False, help="Run with all optimization tests." ) + parser.addoption( + "--check-config-side-effect", + action="store_true", + default=False, + help="Verify that user config file is non-existent (will also fail if user config file exists before test run).", + ) @pytest.fixture @@ -49,97 +55,107 @@ def is_full_run(request): yield bool(request.config.getoption("--full-run")) +@pytest.fixture(autouse=True) +def config_mixin(config_eos): + with patch( + "akkudoktoreos.core.coreabc.ConfigMixin.config", new_callable=PropertyMock + ) as config_mixin_patch: + config_mixin_patch.return_value = config_eos + yield config_mixin_patch + + +# Test if test has side effect of writing to system (user) config file +# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json) +@pytest.fixture(autouse=True) +def cfg_non_existent(request): + yield + if bool(request.config.getoption("--check-config-side-effect")): + from platformdirs import user_config_dir + + user_dir = user_config_dir(ConfigEOS.APP_NAME) + assert not Path(user_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME).exists() + + +@pytest.fixture(autouse=True) +def user_config_dir(config_default_dirs): + with patch( + "akkudoktoreos.config.config.user_config_dir", + return_value=str(config_default_dirs[0]), + ) as user_dir_patch: + yield user_dir_patch + + +@pytest.fixture(autouse=True) +def user_data_dir(config_default_dirs): + with patch( + "akkudoktoreos.config.config.user_data_dir", + return_value=str(config_default_dirs[-1] / "data"), + ) as user_dir_patch: + yield user_dir_patch + + @pytest.fixture -def reset_config(disable_debug_logging): +def config_eos( + disable_debug_logging, + user_config_dir, + user_data_dir, + config_default_dirs, + monkeypatch, +) -> ConfigEOS: """Fixture to reset EOS config to default values.""" + monkeypatch.setenv("data_cache_subpath", str(config_default_dirs[-1] / "data/cache")) + monkeypatch.setenv("data_output_subpath", str(config_default_dirs[-1] / "data/output")) + config_file = config_default_dirs[0] / ConfigEOS.CONFIG_FILE_NAME + assert not config_file.exists() config_eos = get_config() config_eos.reset_settings() - config_eos.reset_to_defaults() + assert config_file == config_eos.config_file_path + assert config_file.exists() + assert config_default_dirs[-1] / "data" == config_eos.data_folder_path + assert config_default_dirs[-1] / "data/cache" == config_eos.data_cache_path + assert config_default_dirs[-1] / "data/output" == config_eos.data_output_path return config_eos @pytest.fixture def config_default_dirs(): """Fixture that provides a list of directories to be used as config dir.""" - config_eos = get_config() - # Default config directory from platform user config directory - config_default_dir_user = Path(platformdirs.user_config_dir(config_eos.APP_NAME)) - # Default config directory from current working directory - config_default_dir_cwd = Path.cwd() - # Default config directory from default config file - config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data") - return config_default_dir_user, config_default_dir_cwd, config_default_dir_default + with tempfile.TemporaryDirectory() as tmp_user_home_dir: + # Default config directory from platform user config directory + config_default_dir_user = Path(tmp_user_home_dir) / "config" + # Default config directory from current working directory + config_default_dir_cwd = Path.cwd() + # Default config directory from default config file + config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data") + + # Default data directory from platform user data directory + data_default_dir_user = Path(tmp_user_home_dir) + yield ( + config_default_dir_user, + config_default_dir_cwd, + config_default_dir_default, + data_default_dir_user, + ) @pytest.fixture -def stash_config_file(config_default_dirs): - """Fixture to temporarily stash away an existing config file during a test. - - If the specified config file exists, it moves the file to a temporary directory. - The file is restored to its original location after the test. - - Keep right most in fixture parameter list to assure application at last. - - Returns: - Path: Path to the stashed config file. - """ - config_eos = get_config() - config_default_dir_user, config_default_dir_cwd, _ = config_default_dirs - - config_file_path_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME) - config_file_path_cwd = config_default_dir_cwd.joinpath(config_eos.CONFIG_FILE_NAME) - - original_config_file_user = None - original_config_file_cwd = None - if config_file_path_user.exists(): - original_config_file_user = config_file_path_user - if config_file_path_cwd.exists(): - original_config_file_cwd = config_file_path_cwd - - temp_dir = tempfile.TemporaryDirectory() - temp_file_user = None - temp_file_cwd = None - - # If the file exists, move it to the temporary directory - if original_config_file_user: - temp_file_user = Path(temp_dir.name) / f"user.{original_config_file_user.name}" - shutil.move(original_config_file_user, temp_file_user) - assert not original_config_file_user.exists() - logger.debug(f"Stashed: '{original_config_file_user}'") - if original_config_file_cwd: - temp_file_cwd = Path(temp_dir.name) / f"cwd.{original_config_file_cwd.name}" - shutil.move(original_config_file_cwd, temp_file_cwd) - assert not original_config_file_cwd.exists() - logger.debug(f"Stashed: '{original_config_file_cwd}'") - - # Yield the temporary file path to the test - yield temp_file_user, temp_file_cwd - - # Cleanup after the test - if temp_file_user: - # Restore the file to its original location - shutil.move(temp_file_user, original_config_file_user) - assert original_config_file_user.exists() - if temp_file_cwd: - # Restore the file to its original location - shutil.move(temp_file_cwd, original_config_file_cwd) - assert original_config_file_cwd.exists() - temp_dir.cleanup() - - -@pytest.fixture -def server(xprocess, tmp_path: Path): +def server(xprocess, config_eos, config_default_dirs): """Fixture to start the server. Provides URL of the server. """ class Starter(ProcessStarter): + # Set environment before any subprocess run, to keep custom config dir + env = os.environ.copy() + env["EOS_DIR"] = str(config_default_dirs[-1]) + # assure server to be installed try: subprocess.run( [sys.executable, "-c", "import akkudoktoreos.server.fastapi_server"], check=True, + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -154,11 +170,6 @@ def server(xprocess, tmp_path: Path): # command to start server process args = [sys.executable, "-m", "akkudoktoreos.server.fastapi_server"] - config_eos = get_config() - settings = { - "data_folder_path": tmp_path, - } - config_eos.merge_settings_from_dict(settings) # startup pattern pattern = "Application startup complete." @@ -174,6 +185,7 @@ def server(xprocess, tmp_path: Path): # ensure process is running and return its logfile pid, logfile = xprocess.ensure("eos", Starter) + print(f"View xprocess logfile at: {logfile}") # create url/port info to the server url = "http://127.0.0.1:8503" diff --git a/tests/test_class_ems.py b/tests/test_class_ems.py index 4e74041..1a32d4c 100644 --- a/tests/test_class_ems.py +++ b/tests/test_class_ems.py @@ -3,7 +3,6 @@ from pathlib import Path import numpy as np import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import ( EnergieManagementSystem, EnergieManagementSystemParameters, @@ -24,10 +23,9 @@ start_hour = 1 # Example initialization of necessary components @pytest.fixture -def create_ems_instance() -> EnergieManagementSystem: +def create_ems_instance(config_eos) -> EnergieManagementSystem: """Fixture to create an EnergieManagementSystem instance with given test parameters.""" # Assure configuration holds the correct values - config_eos = get_config() config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24}) assert config_eos.prediction_hours is not None diff --git a/tests/test_class_ems_2.py b/tests/test_class_ems_2.py index 1a11a71..eda16ce 100644 --- a/tests/test_class_ems_2.py +++ b/tests/test_class_ems_2.py @@ -3,7 +3,6 @@ from pathlib import Path import numpy as np import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import ( EnergieManagementSystem, EnergieManagementSystemParameters, @@ -23,10 +22,9 @@ start_hour = 0 # Example initialization of necessary components @pytest.fixture -def create_ems_instance() -> EnergieManagementSystem: +def create_ems_instance(config_eos) -> EnergieManagementSystem: """Fixture to create an EnergieManagementSystem instance with given test parameters.""" # Assure configuration holds the correct values - config_eos = get_config() config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24}) assert config_eos.prediction_hours is not None diff --git a/tests/test_class_optimize.py b/tests/test_class_optimize.py index dedf869..641d122 100644 --- a/tests/test_class_optimize.py +++ b/tests/test_class_optimize.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest -from akkudoktoreos.config.config import get_config +from akkudoktoreos.config.config import ConfigEOS from akkudoktoreos.optimization.genetic import ( OptimizationParameters, OptimizeResponse, @@ -40,10 +40,15 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]): ("optimize_input_2.json", "optimize_result_2_full.json", 400), ], ) -def test_optimize(fn_in: str, fn_out: str, ngen: int, is_full_run: bool): +def test_optimize( + fn_in: str, + fn_out: str, + ngen: int, + config_eos: ConfigEOS, + is_full_run: bool, +): """Test optimierung_ems.""" # Assure configuration holds the correct values - config_eos = get_config() config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48}) # Load input and output data @@ -93,3 +98,4 @@ def test_optimize(fn_in: str, fn_out: str, ngen: int, is_full_run: bool): # The function creates a visualization result PDF as a side-effect. prepare_visualize_patch.assert_called_once() + assert Path(visualize_filename).exists() diff --git a/tests/test_config.py b/tests/test_config.py index 6fbc1e7..552280d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,43 +1,33 @@ -import os -import shutil import tempfile from pathlib import Path +from unittest.mock import patch import pytest -from akkudoktoreos.config.config import ConfigEOS, get_config +from akkudoktoreos.config.config import ConfigEOS from akkudoktoreos.utils.logutil import get_logger logger = get_logger(__name__) -config_eos = get_config() -DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") - -FILE_TESTDATA_CONFIGEOS_1_JSON = DIR_TESTDATA.joinpath(config_eos.CONFIG_FILE_NAME) -FILE_TESTDATA_CONFIGEOS_1_DIR = FILE_TESTDATA_CONFIGEOS_1_JSON.parent +# overwrite config_mixin fixture from conftest +@pytest.fixture(autouse=True) +def config_mixin(): + pass -@pytest.fixture -def reset_config_singleton(): - """Fixture to reset the ConfigEOS singleton instance before a test.""" - ConfigEOS.reset_instance() - yield - ConfigEOS.reset_instance() - - -def test_fixture_stash_config_file(stash_config_file, config_default_dirs): +def test_fixture_new_config_file(config_default_dirs): """Assure fixture stash_config_file is working.""" - config_default_dir_user, config_default_dir_cwd, _ = config_default_dirs + config_default_dir_user, config_default_dir_cwd, _, _ = config_default_dirs - config_file_path_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME) - config_file_path_cwd = config_default_dir_cwd.joinpath(config_eos.CONFIG_FILE_NAME) + config_file_path_user = config_default_dir_user.joinpath(ConfigEOS.CONFIG_FILE_NAME) + config_file_path_cwd = config_default_dir_cwd.joinpath(ConfigEOS.CONFIG_FILE_NAME) assert not config_file_path_user.exists() assert not config_file_path_cwd.exists() -def test_config_constants(): +def test_config_constants(config_eos): """Test config constants are the way expected by the tests.""" assert config_eos.APP_NAME == "net.akkudoktor.eos" assert config_eos.APP_AUTHOR == "akkudoktor" @@ -46,7 +36,7 @@ def test_config_constants(): assert config_eos.CONFIG_FILE_NAME == "EOS.config.json" -def test_computed_paths(reset_config): +def test_computed_paths(config_eos): """Test computed paths for output and cache.""" config_eos.merge_settings_from_dict( { @@ -57,56 +47,81 @@ def test_computed_paths(reset_config): ) assert config_eos.data_output_path == Path("/base/data/output") assert config_eos.data_cache_path == Path("/base/data/cache") + # reset settings so the config_eos fixture can verify the default paths + config_eos.reset_settings() -def test_singleton_behavior(reset_config_singleton): +def test_singleton_behavior(config_eos, config_default_dirs): """Test that ConfigEOS behaves as a singleton.""" - instance1 = ConfigEOS() - instance2 = ConfigEOS() + initial_cfg_file = config_eos.config_file_path + with patch( + "akkudoktoreos.config.config.user_config_dir", return_value=str(config_default_dirs[0]) + ): + instance1 = ConfigEOS() + instance2 = ConfigEOS() + assert instance1 is config_eos assert instance1 is instance2 + assert instance1.config_file_path == initial_cfg_file -def test_default_config_path(reset_config, config_default_dirs, stash_config_file): +def test_default_config_path(config_eos, config_default_dirs): """Test that the default config file path is computed correctly.""" - _, _, config_default_dir_default = config_default_dirs + _, _, config_default_dir_default, _ = config_default_dirs expected_path = config_default_dir_default.joinpath("default.config.json") assert config_eos.config_default_file_path == expected_path assert config_eos.config_default_file_path.is_file() -def test_config_folder_path(reset_config, config_default_dirs, stash_config_file, monkeypatch): - """Test that _config_folder_path identifies the correct config directory or None.""" - config_default_dir_user, _, _ = config_default_dirs +@patch("akkudoktoreos.config.config.user_config_dir") +def test_get_config_file_path(user_config_dir_patch, config_eos, config_default_dirs, monkeypatch): + """Test that _get_config_file_path identifies the correct config file.""" + config_default_dir_user, _, _, _ = config_default_dirs + user_config_dir_patch.return_value = str(config_default_dir_user) - # All config files are stashed away, no config folder path - assert config_eos._config_folder_path() is None + def cfg_file(dir: Path) -> Path: + return dir.joinpath(ConfigEOS.CONFIG_FILE_NAME) - config_file_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME) - shutil.copy2(config_eos.config_default_file_path, config_file_user) - assert config_eos._config_folder_path() == config_default_dir_user + # Config newly created from fixture with fresh user config directory + assert config_eos._get_config_file_path() == (cfg_file(config_default_dir_user), True) + cfg_file(config_default_dir_user).unlink() - monkeypatch.setenv("EOS_DIR", str(FILE_TESTDATA_CONFIGEOS_1_DIR)) - assert config_eos._config_folder_path() == FILE_TESTDATA_CONFIGEOS_1_DIR + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir_path = Path(temp_dir) + monkeypatch.setenv("EOS_DIR", str(temp_dir_path)) + assert config_eos._get_config_file_path() == (cfg_file(temp_dir_path), False) - # Cleanup after the test - os.remove(config_file_user) + monkeypatch.setenv("EOS_CONFIG_DIR", "config") + assert config_eos._get_config_file_path() == ( + cfg_file(temp_dir_path / "config"), + False, + ) + + monkeypatch.setenv("EOS_CONFIG_DIR", str(temp_dir_path / "config2")) + assert config_eos._get_config_file_path() == ( + cfg_file(temp_dir_path / "config2"), + False, + ) + + monkeypatch.delenv("EOS_DIR") + monkeypatch.setenv("EOS_CONFIG_DIR", "config3") + assert config_eos._get_config_file_path() == (cfg_file(config_default_dir_user), False) + + monkeypatch.setenv("EOS_CONFIG_DIR", str(temp_dir_path / "config3")) + assert config_eos._get_config_file_path() == ( + cfg_file(temp_dir_path / "config3"), + False, + ) -def test_config_copy(reset_config, stash_config_file, monkeypatch): +def test_config_copy(config_eos, monkeypatch): """Test if the config is copied to the provided path.""" - temp_dir = tempfile.TemporaryDirectory() - temp_folder_path = Path(temp_dir.name) - temp_config_file_path = temp_folder_path.joinpath(config_eos.CONFIG_FILE_NAME).resolve() - monkeypatch.setenv(config_eos.EOS_DIR, str(temp_folder_path)) - if temp_config_file_path.exists(): - temp_config_file_path.unlink() - assert not temp_config_file_path.exists() - assert config_eos._config_folder_path() is None - assert config_eos._config_file_path() == temp_config_file_path - - config_eos.from_config_file() - assert temp_config_file_path.exists() - - # Cleanup after the test - temp_dir.cleanup() + with tempfile.TemporaryDirectory() as temp_dir: + temp_folder_path = Path(temp_dir) + temp_config_file_path = temp_folder_path.joinpath(config_eos.CONFIG_FILE_NAME).resolve() + monkeypatch.setenv(config_eos.EOS_DIR, str(temp_folder_path)) + assert not temp_config_file_path.exists() + with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir): + assert config_eos._get_config_file_path() == (temp_config_file_path, False) + config_eos.from_config_file() + assert temp_config_file_path.exists() diff --git a/tests/test_dataabc.py b/tests/test_dataabc.py index cc8e1ea..25361ca 100644 --- a/tests/test_dataabc.py +++ b/tests/test_dataabc.py @@ -113,7 +113,7 @@ class DerivedDataContainer(DataContainer): class TestDataBase: @pytest.fixture - def base(self, reset_config, monkeypatch): + def base(self): # Provide default values for configuration derived = DerivedBase() derived.config.update() diff --git a/tests/test_elecpriceakkudoktor.py b/tests/test_elecpriceakkudoktor.py index 572f846..6aa4208 100644 --- a/tests/test_elecpriceakkudoktor.py +++ b/tests/test_elecpriceakkudoktor.py @@ -21,8 +21,6 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath( "elecpriceforecast_akkudoktor_1.json" ) -ems_eos = get_ems() - @pytest.fixture def elecprice_provider(monkeypatch): @@ -136,6 +134,7 @@ def test_update_data(mock_get, elecprice_provider, sample_akkudoktor_1_json, cac cache_store.clear(clear_all=True) # Call the method + ems_eos = get_ems() ems_eos.set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")) elecprice_provider.update_data(force_enable=True, force_update=True) diff --git a/tests/test_elecpriceimport.py b/tests/test_elecpriceimport.py index 13a5507..ee33126 100644 --- a/tests/test_elecpriceimport.py +++ b/tests/test_elecpriceimport.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime @@ -12,12 +11,9 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json") -config_eos = get_config() -ems_eos = get_ems() - @pytest.fixture -def elecprice_provider(reset_config, sample_import_1_json): +def elecprice_provider(sample_import_1_json, config_eos): """Fixture to create a ElecPriceProvider instance.""" settings = { "elecprice_provider": "ElecPriceImport", @@ -26,7 +22,7 @@ def elecprice_provider(reset_config, sample_import_1_json): } config_eos.merge_settings_from_dict(settings) provider = ElecPriceImport() - assert provider.enabled() == True + assert provider.enabled() return provider @@ -49,14 +45,14 @@ def test_singleton_instance(elecprice_provider): assert elecprice_provider is another_instance -def test_invalid_provider(elecprice_provider): +def test_invalid_provider(elecprice_provider, config_eos): """Test requesting an unsupported elecprice_provider.""" settings = { "elecprice_provider": "", "elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON), } config_eos.merge_settings_from_dict(settings) - assert elecprice_provider.enabled() == False + assert not elecprice_provider.enabled() # ------------------------------------------------ @@ -77,8 +73,9 @@ def test_invalid_provider(elecprice_provider): ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day) ], ) -def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_file): +def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_file, config_eos): """Test fetching forecast from Import.""" + ems_eos = get_ems() ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) if from_file: config_eos.elecpriceimport_json = None diff --git a/tests/test_loadakkudoktor.py b/tests/test_loadakkudoktor.py index 95cb0bc..fcb6131 100644 --- a/tests/test_loadakkudoktor.py +++ b/tests/test_loadakkudoktor.py @@ -4,7 +4,6 @@ import numpy as np import pendulum import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.measurement.measurement import MeasurementDataRecord, get_measurement from akkudoktoreos.prediction.loadakkudoktor import ( @@ -13,12 +12,9 @@ from akkudoktoreos.prediction.loadakkudoktor import ( ) from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration -config_eos = get_config() -ems_eos = get_ems() - @pytest.fixture -def load_provider(): +def load_provider(config_eos): """Fixture to initialise the LoadAkkudoktor instance.""" settings = { "load_provider": "LoadAkkudoktor", @@ -112,6 +108,7 @@ def test_update_data(mock_load_data, load_provider): mock_load_data.return_value = np.random.rand(365, 2, 24) # Mock methods for updating values + ems_eos = get_ems() ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1)) # Assure there are no prediction records diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 0edcb0d..d0ad24c 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -1,17 +1,20 @@ import json from pathlib import Path - -from generate_openapi import generate_openapi +from unittest.mock import patch DIR_PROJECT_ROOT = Path(__file__).parent.parent DIR_TESTDATA = Path(__file__).parent / "testdata" -def test_openapi_spec_current(): +def test_openapi_spec_current(config_eos): """Verify the openapi spec hasn“t changed.""" old_spec_path = DIR_PROJECT_ROOT / "docs" / "akkudoktoreos" / "openapi.json" new_spec_path = DIR_TESTDATA / "openapi-new.json" - generate_openapi(new_spec_path) + # Patch get_config and import within guard to patch global variables within the fastapi_server module. + with patch("akkudoktoreos.config.config.get_config", return_value=config_eos): + from generate_openapi import generate_openapi + + generate_openapi(new_spec_path) with open(new_spec_path) as f_new: new_spec = json.load(f_new) with open(old_spec_path) as f_old: diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 7ce5a18..27d0f1b 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -1,7 +1,6 @@ import pytest from pydantic import ValidationError -from akkudoktoreos.config.config import get_config from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor @@ -19,7 +18,7 @@ from akkudoktoreos.prediction.weatherimport import WeatherImport @pytest.fixture -def sample_settings(reset_config): +def sample_settings(config_eos): """Fixture that adds settings data to the global config.""" settings = { "prediction_hours": 48, @@ -33,9 +32,8 @@ def sample_settings(reset_config): } # Merge settings to config - config = get_config() - config.merge_settings_from_dict(settings) - return config + config_eos.merge_settings_from_dict(settings) + return config_eos @pytest.fixture diff --git a/tests/test_predictionabc.py b/tests/test_predictionabc.py index b24edf7..5e6d2ff 100644 --- a/tests/test_predictionabc.py +++ b/tests/test_predictionabc.py @@ -7,7 +7,6 @@ import pendulum import pytest from pydantic import Field -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.prediction.prediction import PredictionCommonSettings from akkudoktoreos.prediction.predictionabc import ( @@ -87,7 +86,7 @@ class DerivedPredictionContainer(PredictionContainer): class TestPredictionBase: @pytest.fixture - def base(self, reset_config, monkeypatch): + def base(self, monkeypatch): # Provide default values for configuration monkeypatch.setenv("latitude", "50.0") monkeypatch.setenv("longitude", "10.0") @@ -177,10 +176,11 @@ class TestPredictionProvider: provider.keep_datetime == expected_keep_datetime ), "Keep datetime is not calculated correctly." - def test_update_method_with_defaults(self, provider, sample_start_datetime, monkeypatch): + def test_update_method_with_defaults( + self, provider, sample_start_datetime, config_eos, monkeypatch + ): """Test the `update` method with default parameters.""" # EOS config supersedes - config_eos = get_config() ems_eos = get_ems() # The following values are currently not set in EOS config, we can override monkeypatch.setenv("prediction_historic_hours", "2") diff --git a/tests/test_pvforecastakkudoktor.py b/tests/test_pvforecastakkudoktor.py index 63c300b..e437cef 100644 --- a/tests/test_pvforecastakkudoktor.py +++ b/tests/test_pvforecastakkudoktor.py @@ -4,7 +4,6 @@ from unittest.mock import Mock, patch import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.prediction.prediction import get_prediction from akkudoktoreos.prediction.pvforecastakkudoktor import ( @@ -22,12 +21,8 @@ FILE_TESTDATA_PV_FORECAST_INPUT_1 = DIR_TESTDATA.joinpath("pv_forecast_input_1.j FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt") -config_eos = get_config() -ems_eos = get_ems() - - @pytest.fixture -def sample_settings(reset_config): +def sample_settings(config_eos): """Fixture that adds settings data to the global config.""" settings = { "prediction_hours": 48, @@ -223,6 +218,7 @@ def test_pvforecast_akkudoktor_update_with_sample_forecast( mock_get.return_value = mock_response # Test that update properly inserts data records + ems_eos = get_ems() ems_eos.set_start_datetime(sample_forecast_start) provider.update_data(force_enable=True, force_update=True) assert compare_datetimes(provider.start_datetime, sample_forecast_start).equal @@ -230,10 +226,9 @@ def test_pvforecast_akkudoktor_update_with_sample_forecast( # Report Generation Test -def test_report_ac_power_and_measurement(provider): +def test_report_ac_power_and_measurement(provider, config_eos): # Set the configuration - config = get_config() - config.merge_settings_from_dict(sample_config_data) + config_eos.merge_settings_from_dict(sample_config_data) record = PVForecastAkkudoktorDataRecord( pvforecastakkudoktor_ac_power_measured=900.0, @@ -275,6 +270,7 @@ def test_timezone_behaviour( provider.clear() assert len(provider) == 0 + ems_eos = get_ems() ems_eos.set_start_datetime(other_start_datetime) provider.update_data(force_update=True) assert compare_datetimes(provider.start_datetime, other_start_datetime).equal diff --git a/tests/test_pvforecastimport.py b/tests/test_pvforecastimport.py index b108aac..863fef1 100644 --- a/tests/test_pvforecastimport.py +++ b/tests/test_pvforecastimport.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.prediction.pvforecastimport import PVForecastImport from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime @@ -12,12 +11,9 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json") -config_eos = get_config() -ems_eos = get_ems() - @pytest.fixture -def pvforecast_provider(reset_config, sample_import_1_json): +def pvforecast_provider(sample_import_1_json, config_eos): """Fixture to create a PVForecastProvider instance.""" settings = { "pvforecast_provider": "PVForecastImport", @@ -26,7 +22,7 @@ def pvforecast_provider(reset_config, sample_import_1_json): } config_eos.merge_settings_from_dict(settings) provider = PVForecastImport() - assert provider.enabled() == True + assert provider.enabled() return provider @@ -49,14 +45,14 @@ def test_singleton_instance(pvforecast_provider): assert pvforecast_provider is another_instance -def test_invalid_provider(pvforecast_provider): +def test_invalid_provider(pvforecast_provider, config_eos): """Test requesting an unsupported pvforecast_provider.""" settings = { "pvforecast_provider": "", "pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON), } config_eos.merge_settings_from_dict(settings) - assert pvforecast_provider.enabled() == False + assert not pvforecast_provider.enabled() # ------------------------------------------------ @@ -77,8 +73,9 @@ def test_invalid_provider(pvforecast_provider): ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day) ], ) -def test_import(pvforecast_provider, sample_import_1_json, start_datetime, from_file): +def test_import(pvforecast_provider, sample_import_1_json, start_datetime, from_file, config_eos): """Test fetching forecast from import.""" + ems_eos = get_ems() ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) if from_file: config_eos.pvforecastimport_json = None diff --git a/tests/test_server.py b/tests/test_server.py index a68979e..d8260ab 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,12 +2,8 @@ from http import HTTPStatus import requests -from akkudoktoreos.config.config import get_config -config_eos = get_config() - - -def test_server(server): +def test_server(server, config_eos): """Test the server.""" # validate correct path in server assert config_eos.data_folder_path is not None diff --git a/tests/test_visualize.py b/tests/test_visualize.py index 3a6e46d..9ca79c0 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -1,32 +1,28 @@ -import os from pathlib import Path from matplotlib.testing.compare import compare_images -from akkudoktoreos.config.config import get_config from akkudoktoreos.utils.visualize import generate_example_report filename = "example_report.pdf" -config = get_config() -output_dir = config.data_output_path -output_dir.mkdir(parents=True, exist_ok=True) -output_file = os.path.join(output_dir, filename) DIR_TESTDATA = Path(__file__).parent / "testdata" reference_file = DIR_TESTDATA / "test_example_report.pdf" -def test_generate_pdf_example(): +def test_generate_pdf_example(config_eos): """Test generation of example visualization report.""" - # Delete the old generated file if it exists - if os.path.isfile(output_file): - os.remove(output_file) + output_dir = config_eos.data_output_path + assert output_dir is not None + output_file = output_dir / filename + assert not output_file.exists() - generate_example_report(filename) + # Generate PDF + generate_example_report() # Check if the file exists - assert os.path.isfile(output_file) + assert output_file.exists() # Compare the generated file with the reference file comparison = compare_images(str(reference_file), str(output_file), tol=0) diff --git a/tests/test_weatherbrightsky.py b/tests/test_weatherbrightsky.py index bf7395c..555057b 100644 --- a/tests/test_weatherbrightsky.py +++ b/tests/test_weatherbrightsky.py @@ -15,8 +15,6 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_1.json") FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_2.json") -ems_eos = get_ems() - @pytest.fixture def weather_provider(monkeypatch): @@ -64,7 +62,7 @@ def test_invalid_provider(weather_provider, monkeypatch): """Test requesting an unsupported weather_provider.""" monkeypatch.setenv("weather_provider", "") weather_provider.config.update() - assert weather_provider.enabled() == False + assert not weather_provider.enabled() def test_invalid_coordinates(weather_provider, monkeypatch): @@ -163,6 +161,7 @@ def test_update_data(mock_get, weather_provider, sample_brightsky_1_json, cache_ cache_store.clear(clear_all=True) # Call the method + ems_eos = get_ems() ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin")) weather_provider.update_data(force_enable=True, force_update=True) diff --git a/tests/test_weatherclearoutside.py b/tests/test_weatherclearoutside.py index ca4c2ee..54aec87 100644 --- a/tests/test_weatherclearoutside.py +++ b/tests/test_weatherclearoutside.py @@ -9,7 +9,6 @@ import pvlib import pytest from bs4 import BeautifulSoup -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside from akkudoktoreos.utils.cacheutil import CacheFileStore @@ -20,12 +19,9 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_HTML = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.html") FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.json") -config_eos = get_config() -ems_eos = get_ems() - @pytest.fixture -def weather_provider(): +def weather_provider(config_eos): """Fixture to create a WeatherProvider instance.""" settings = { "weather_provider": "ClearOutside", @@ -70,16 +66,16 @@ def test_singleton_instance(weather_provider): assert weather_provider is another_instance -def test_invalid_provider(weather_provider): +def test_invalid_provider(weather_provider, config_eos): """Test requesting an unsupported weather_provider.""" settings = { "weather_provider": "", } config_eos.merge_settings_from_dict(settings) - assert weather_provider.enabled() == False + assert not weather_provider.enabled() -def test_invalid_coordinates(weather_provider): +def test_invalid_coordinates(weather_provider, config_eos): """Test invalid coordinates raise ValueError.""" settings = { "weather_provider": "ClearOutside", @@ -118,7 +114,7 @@ def test_irridiance_estimate_from_cloud_cover(weather_provider): @patch("requests.get") -def test_request_forecast(mock_get, weather_provider, sample_clearout_1_html): +def test_request_forecast(mock_get, weather_provider, sample_clearout_1_html, config_eos): """Test fetching forecast from ClearOutside.""" # Mock response object mock_response = Mock() @@ -149,6 +145,7 @@ def test_update_data(mock_get, weather_provider, sample_clearout_1_html, sample_ expected_keep = to_datetime("2024-10-24 00:00:00", in_timezone="Europe/Berlin") # Call the method + ems_eos = get_ems() ems_eos.set_start_datetime(expected_start) weather_provider.update_data() diff --git a/tests/test_weatherimport.py b/tests/test_weatherimport.py index 0aeeb38..d54288a 100644 --- a/tests/test_weatherimport.py +++ b/tests/test_weatherimport.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest -from akkudoktoreos.config.config import get_config from akkudoktoreos.core.ems import get_ems from akkudoktoreos.prediction.weatherimport import WeatherImport from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime @@ -12,12 +11,9 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json") -config_eos = get_config() -ems_eos = get_ems() - @pytest.fixture -def weather_provider(reset_config, sample_import_1_json): +def weather_provider(sample_import_1_json, config_eos): """Fixture to create a WeatherProvider instance.""" settings = { "weather_provider": "WeatherImport", @@ -49,7 +45,7 @@ def test_singleton_instance(weather_provider): assert weather_provider is another_instance -def test_invalid_provider(weather_provider, monkeypatch): +def test_invalid_provider(weather_provider, config_eos, monkeypatch): """Test requesting an unsupported weather_provider.""" settings = { "weather_provider": "", @@ -77,8 +73,9 @@ def test_invalid_provider(weather_provider, monkeypatch): ("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day) ], ) -def test_import(weather_provider, sample_import_1_json, start_datetime, from_file): +def test_import(weather_provider, sample_import_1_json, start_datetime, from_file, config_eos): """Test fetching forecast from Import.""" + ems_eos = get_ems() ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) if from_file: config_eos.weatherimport_json = None