Reasonable defaults, isolate tests, EOS_LOGGING_LEVEL, EOS_CONFIG_DIR

* Add EOS_CONFIG_DIR to set config dir (relative path to EOS_DIR or
   absolute path).
    - config_folder_path read-only
    - config_file_path read-only
 * Default values to support app start with empty config:
    - latitude/longitude (Berlin)
    - optimization_ev_available_charge_rates_percent (null, so model
      default value is used)
    - Enable Akkudoktor electricity price forecast (docker-compose).
 * Fix some endpoints (empty data, remove unused params, fix types).
 * cacheutil: Use cache dir. Closes #240
 * Support EOS_LOGGING_LEVEL environment variable to set log level.
 * tests: All tests use separate temporary config
    - Add pytest switch --check-config-side-effect to check user
      config file existence after each test. Will also fail if user config
      existed before test execution (but will only check after the test has
      run).
      Enable flag in github workflow.
    - Globally mock platformdirs in config module. Now no longer required
      to patch individually.
      Function calls to config instance (e.g. merge_settings_from_dict)
      were unaffected previously.
 * Set Berlin as default location (default config/docker-compose).
This commit is contained in:
Dominique Lasserre 2024-12-30 13:41:39 +01:00 committed by GitHub
parent 267a9bf427
commit 75987db9e1
29 changed files with 373 additions and 375 deletions

View File

@ -26,7 +26,7 @@ jobs:
- name: Run Pytest - name: Run Pytest
run: | run: |
pip install -e . 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 - name: Upload test artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@ -9,6 +9,7 @@ ENV MPLCONFIGDIR="/tmp/mplconfigdir"
ENV EOS_DIR="/opt/eos" ENV EOS_DIR="/opt/eos"
ENV EOS_CACHE_DIR="${EOS_DIR}/cache" ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
ENV EOS_OUTPUT_DIR="${EOS_DIR}/output" ENV EOS_OUTPUT_DIR="${EOS_DIR}/output"
ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
WORKDIR ${EOS_DIR} WORKDIR ${EOS_DIR}
@ -18,7 +19,9 @@ RUN adduser --system --group --no-create-home eos \
&& mkdir -p "${EOS_CACHE_DIR}" \ && mkdir -p "${EOS_CACHE_DIR}" \
&& chown eos "${EOS_CACHE_DIR}" \ && chown eos "${EOS_CACHE_DIR}" \
&& mkdir -p "${EOS_OUTPUT_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 . COPY requirements.txt .
@ -34,4 +37,4 @@ EXPOSE 8503
CMD ["python", "-m", "akkudoktoreos.server.fastapi_server"] 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}"]

View File

@ -11,7 +11,11 @@ services:
dockerfile: "Dockerfile" dockerfile: "Dockerfile"
args: args:
PYTHON_VERSION: "${PYTHON_VERSION}" PYTHON_VERSION: "${PYTHON_VERSION}"
volumes: environment:
- ./src/akkudoktoreos/default.config.json:/opt/eos/EOS.config.json:ro - EOS_CONFIG_DIR=config
- latitude=52.2
- longitude=13.4
- elecprice_provider=ElecPriceAkkudoktor
- elecprice_charges=0.21
ports: ports:
- "${EOS_PORT}:${EOS_PORT}" - "${EOS_PORT}:${EOS_PORT}"

View File

@ -778,26 +778,8 @@
}, },
"/pvforecast": { "/pvforecast": {
"get": { "get": {
"summary": "Fastapi Pvprognose", "summary": "Fastapi Pvforecast",
"operationId": "fastapi_pvprognose_pvforecast_get", "operationId": "fastapi_pvforecast_pvforecast_get",
"parameters": [
{
"name": "ac_power_measurement",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Ac Power Measurement"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "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.", "description": "Sub-path for the EOS cache data directory.",
"default": "cache" "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": { "pvforecast_planes": {
"items": { "items": {
"type": "string" "type": "string"
@ -3100,6 +3046,34 @@
"description": "Compute data_cache_path based on data_folder_path.", "description": "Compute data_cache_path based on data_folder_path.",
"readOnly": true "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": { "config_default_file_path": {
"type": "string", "type": "string",
"format": "path", "format": "path",
@ -3128,11 +3102,13 @@
"timezone", "timezone",
"data_output_path", "data_output_path",
"data_cache_path", "data_cache_path",
"config_folder_path",
"config_file_path",
"config_default_file_path", "config_default_file_path",
"config_keys" "config_keys"
], ],
"title": "ConfigEOS", "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": { "ElectricVehicleParameters": {
"properties": { "properties": {

View File

@ -14,7 +14,7 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar, List, Optional 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 from pydantic import Field, ValidationError, computed_field
# settings # settings
@ -40,6 +40,24 @@ from akkudoktoreos.utils.utils import UtilsCommonSettings
logger = get_logger(__name__) 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): class ConfigCommonSettings(SettingsBaseModel):
"""Settings for common configuration.""" """Settings for common configuration."""
@ -60,22 +78,14 @@ class ConfigCommonSettings(SettingsBaseModel):
@property @property
def data_output_path(self) -> Optional[Path]: def data_output_path(self) -> Optional[Path]:
"""Compute data_output_path based on data_folder_path.""" """Compute data_output_path based on data_folder_path."""
if self.data_output_subpath is None: return get_absolute_path(self.data_folder_path, self.data_output_subpath)
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
# Computed fields # Computed fields
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def data_cache_path(self) -> Optional[Path]: def data_cache_path(self) -> Optional[Path]:
"""Compute data_cache_path based on data_folder_path.""" """Compute data_cache_path based on data_folder_path."""
if self.data_cache_subpath is None: return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
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
class SettingsEOS( class SettingsEOS(
@ -114,9 +124,10 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
Initialization Process: Initialization Process:
- Upon instantiation, the singleton instance attempts to load a configuration file in this order: - Upon instantiation, the singleton instance attempts to load a configuration file in this order:
1. The directory specified by the `EOS_DIR` environment variable. 1. The directory specified by the `EOS_CONFIG_DIR` environment variable
2. A platform specific default directory for EOS. 2. The directory specified by the `EOS_DIR` environment variable.
3. The current working directory. 3. A platform specific default directory for EOS.
4. The current working directory.
- The first available configuration file found in these directories is loaded. - 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 - 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. 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_NAME: ClassVar[str] = "net.akkudoktor.eos" # reverse order
APP_AUTHOR: ClassVar[str] = "akkudoktor" APP_AUTHOR: ClassVar[str] = "akkudoktor"
EOS_DIR: ClassVar[str] = "EOS_DIR" EOS_DIR: ClassVar[str] = "EOS_DIR"
EOS_CONFIG_DIR: ClassVar[str] = "EOS_CONFIG_DIR"
ENCODING: ClassVar[str] = "UTF-8" ENCODING: ClassVar[str] = "UTF-8"
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json" CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
_settings: ClassVar[Optional[SettingsEOS]] = None _settings: ClassVar[Optional[SettingsEOS]] = None
_file_settings: ClassVar[Optional[SettingsEOS]] = None _file_settings: ClassVar[Optional[SettingsEOS]] = None
config_folder_path: Optional[Path] = Field( _config_folder_path: Optional[Path] = None
None, description="Path to EOS configuration directory." _config_file_path: Optional[Path] = None
)
config_file_path: Optional[Path] = Field(
default=None, description="Path to EOS configuration file."
)
# Computed fields # 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] @computed_field # type: ignore[prop-decorator]
@property @property
def config_default_file_path(self) -> Path: def config_default_file_path(self) -> Path:
@ -297,7 +316,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
pass pass
# From platform specific default path # From platform specific default path
try: 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: if data_dir is not None:
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
self.data_folder_path = data_dir self.data_folder_path = data_dir
@ -308,47 +327,27 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
data_dir = Path.cwd() data_dir = Path.cwd()
self.data_folder_path = data_dir self.data_folder_path = data_dir
def _config_folder_path(self) -> Optional[Path]: def _get_config_file_path(self) -> tuple[Path, bool]:
"""Finds the first directory containing a valid configuration file. """Finds the a valid configuration file or returns the desired path for a new config file.
Returns: 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_dirs = []
config_dir = None env_base_dir = os.getenv(self.EOS_DIR)
env_dir = os.getenv(self.EOS_DIR) env_config_dir = os.getenv(self.EOS_CONFIG_DIR)
logger.debug(f"Envionment '{self.EOS_DIR}': '{env_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: if env_dir is not None:
config_dirs.append(Path(env_dir).resolve()) config_dirs.append(env_dir.resolve())
config_dirs.append(Path(platformdirs.user_config_dir(self.APP_NAME))) config_dirs.append(Path(user_config_dir(self.APP_NAME)))
config_dirs.append(Path.cwd()) config_dirs.append(Path.cwd())
for cdir in config_dirs: for cdir in config_dirs:
cfile = cdir.joinpath(self.CONFIG_FILE_NAME) cfile = cdir.joinpath(self.CONFIG_FILE_NAME)
if cfile.exists(): if cfile.exists():
logger.debug(f"Found config file: '{cfile}'") logger.debug(f"Found config file: '{cfile}'")
config_dir = cdir return cfile, True
break return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False
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
def from_config_file(self) -> None: def from_config_file(self) -> None:
"""Loads the configuration file settings for EOS. """Loads the configuration file settings for EOS.
@ -356,14 +355,14 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
Raises: Raises:
ValueError: If the configuration file is invalid or incomplete. 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 config_dir = config_file.parent
if not config_file.exists(): if not exists:
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
try: try:
shutil.copy2(self.config_default_file_path, config_file) shutil.copy2(self.config_default_file_path, config_file)
except Exception as exc: 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_file = self.config_default_file_path
config_dir = config_file.parent config_dir = config_file.parent
@ -376,8 +375,8 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
self.update() self.update()
# Everthing worked, remember the values # Everthing worked, remember the values
self.config_folder_path = config_dir self._config_folder_path = config_dir
self.config_file_path = config_file self._config_file_path = config_file
def to_config_file(self) -> None: def to_config_file(self) -> None:
"""Saves the current configuration to the configuration file. """Saves the current configuration to the configuration file.

View File

@ -6,15 +6,16 @@
"data_folder_path": null, "data_folder_path": null,
"data_output_path": null, "data_output_path": null,
"data_output_subpath": null, "data_output_subpath": null,
"elecprice_charges": 0.21,
"elecprice_provider": null, "elecprice_provider": null,
"elecpriceimport_file_path": null, "elecpriceimport_file_path": null,
"latitude": null, "latitude": 52.5,
"load_import_file_path": null, "load_import_file_path": null,
"load_name": null, "load_name": null,
"load_provider": null, "load_provider": null,
"loadakkudoktor_year_energy": null, "loadakkudoktor_year_energy": null,
"longitude": null, "longitude": 13.4,
"optimization_ev_available_charge_rates_percent": [], "optimization_ev_available_charge_rates_percent": null,
"optimization_hours": 48, "optimization_hours": 48,
"optimization_penalty": null, "optimization_penalty": null,
"prediction_historic_hours": 48, "prediction_historic_hours": 48,

View File

@ -123,7 +123,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Decode the input array into ac_charge, dc_charge, and discharge arrays.""" """Decode the input array into ac_charge, dc_charge, and discharge arrays."""
discharge_hours_bin_np = np.array(discharge_hours_bin) 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: # Categorization:
# Idle: 0 .. len_ac-1 # Idle: 0 .. len_ac-1
@ -155,9 +155,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
discharge[discharge_mask] = 1 # Set Discharge states to 1 discharge[discharge_mask] = 1 # Set Discharge states to 1
ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float) ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float)
ac_charge[ac_mask] = [ ac_charge[ac_mask] = [self.possible_charge_values[i] for i in ac_indices]
self.config.optimization_ev_available_charge_rates_percent[i] for i in ac_indices
]
# Idle is just 0, already default. # 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]]: def mutate(self, individual: list[int]) -> tuple[list[int]]:
"""Custom mutation function for the individual.""" """Custom mutation function for the individual."""
# Calculate the number of states # 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: if self.optimize_dc_charge:
total_states = 3 * len_ac + 2 total_states = 3 * len_ac + 2
else: else:
@ -300,7 +298,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
creator.create("Individual", list, fitness=creator.FitnessMin) creator.create("Individual", list, fitness=creator.FitnessMin)
self.toolbox = base.Toolbox() 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: # Total number of states without DC:
# Idle: len_ac states # Idle: len_ac states
@ -378,10 +376,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
if eautocharge_hours_index is not None: if eautocharge_hours_index is not None:
eautocharge_hours_float = np.array( eautocharge_hours_float = np.array(
[ [self.possible_charge_values[i] for i in eautocharge_hours_index],
self.config.optimization_ev_available_charge_rates_percent[i]
for i in eautocharge_hours_index
],
float, float,
) )
self.ems.set_ev_charge_hours(eautocharge_hours_float) self.ems.set_ev_charge_hours(eautocharge_hours_float)
@ -615,10 +610,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
start_solution start_solution
) )
eautocharge_hours_float = ( eautocharge_hours_float = (
[ [self.possible_charge_values[i] for i in eautocharge_hours_index]
self.config.optimization_ev_available_charge_rates_percent[i]
for i in eautocharge_hours_index
]
if eautocharge_hours_index is not None if eautocharge_hours_index is not None
else None else None
) )

View File

@ -7,6 +7,7 @@ from pathlib import Path
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
import httpx import httpx
import pandas as pd
import uvicorn import uvicorn
from fastapi import FastAPI, Query, Request from fastapi import FastAPI, Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
@ -412,20 +413,37 @@ class ForecastResponse(PydanticBaseModel):
@app.get("/pvforecast") @app.get("/pvforecast")
def fastapi_pvprognose(ac_power_measurement: Optional[float] = None) -> ForecastResponse: def fastapi_pvforecast() -> ForecastResponse:
############### ###############
# PV Forecast # PV Forecast
############### ###############
pvforecast_ac_power = prediction_eos["pvforecast_ac_power"] prediction_key = "pvforecast_ac_power"
# Fetch prices for the specified date range pvforecast_ac_power = prediction_eos.get(prediction_key)
pvforecast_ac_power = pvforecast_ac_power.loc[ if pvforecast_ac_power is None:
prediction_eos.start_datetime : prediction_eos.end_datetime raise HTTPException(status_code=404, detail=f"Prediction not available: {prediction_key}")
]
pvforecastakkudoktor_temp_air = prediction_eos["pvforecastakkudoktor_temp_air"] # On empty Series.loc TypeError: Cannot compare tz-naive and tz-aware datetime-like objects
# Fetch prices for the specified date range if len(pvforecast_ac_power) == 0:
pvforecastakkudoktor_temp_air = pvforecastakkudoktor_temp_air.loc[ pvforecast_ac_power = pd.Series()
prediction_eos.start_datetime : prediction_eos.end_datetime 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 both forecasts as a JSON response
return ForecastResponse( return ForecastResponse(
@ -457,8 +475,8 @@ def fastapi_optimize(
def get_pdf() -> PdfResponse: def get_pdf() -> PdfResponse:
# Endpoint to serve the generated PDF with visualization results # Endpoint to serve the generated PDF with visualization results
output_path = config_eos.data_output_path output_path = config_eos.data_output_path
if not output_path.is_dir(): if output_path is None or not output_path.is_dir():
raise ValueError(f"Output path does not exist: {output_path}.") raise HTTPException(status_code=404, detail=f"Output path does not exist: {output_path}.")
file_path = output_path / "visualization_results.pdf" file_path = output_path / "visualization_results.pdf"
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=404, detail="No visualization result available.") raise HTTPException(status_code=404, detail="No visualization result available.")

View File

@ -47,6 +47,7 @@ from typing import (
from pendulum import DateTime, Duration from pendulum import DateTime, Duration
from pydantic import BaseModel, ConfigDict, Field 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.datetimeutil import compare_datetimes, to_datetime, to_duration
from akkudoktoreos.utils.logutil import get_logger from akkudoktoreos.utils.logutil import get_logger
@ -90,7 +91,7 @@ class CacheFileStoreMeta(type, Generic[T]):
return cls._instances[cls] 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. """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 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 # File already available
cache_file_obj = cache_item.cache_file cache_file_obj = cache_item.cache_file
else: else:
self.config.data_cache_path.mkdir(parents=True, exist_ok=True)
cache_file_obj = tempfile.NamedTemporaryFile( 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( self._store[cache_file_key] = CacheFileRecord(
cache_file=cache_file_obj, cache_file=cache_file_obj,

View File

@ -50,6 +50,8 @@ def get_logger(
# Create a logger with the specified name # Create a logger with the specified name
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.propagate = True logger.propagate = True
if (env_level := os.getenv("EOS_LOGGING_LEVEL")) is not None:
logging_level = env_level
if logging_level == "DEBUG": if logging_level == "DEBUG":
level = logging.DEBUG level = logging.DEBUG
elif logging_level == "INFO": elif logging_level == "INFO":

View File

@ -1,17 +1,17 @@
import logging import logging
import shutil import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from unittest.mock import PropertyMock, patch
import pendulum import pendulum
import platformdirs
import pytest import pytest
from xprocess import ProcessStarter 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 from akkudoktoreos.utils.logutil import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@ -42,6 +42,12 @@ def pytest_addoption(parser):
parser.addoption( parser.addoption(
"--full-run", action="store_true", default=False, help="Run with all optimization tests." "--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 @pytest.fixture
@ -49,97 +55,107 @@ def is_full_run(request):
yield bool(request.config.getoption("--full-run")) 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 @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.""" """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 = get_config()
config_eos.reset_settings() 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 return config_eos
@pytest.fixture @pytest.fixture
def config_default_dirs(): def config_default_dirs():
"""Fixture that provides a list of directories to be used as config dir.""" """Fixture that provides a list of directories to be used as config dir."""
config_eos = get_config() with tempfile.TemporaryDirectory() as tmp_user_home_dir:
# Default config directory from platform user config directory # Default config directory from platform user config directory
config_default_dir_user = Path(platformdirs.user_config_dir(config_eos.APP_NAME)) config_default_dir_user = Path(tmp_user_home_dir) / "config"
# Default config directory from current working directory # Default config directory from current working directory
config_default_dir_cwd = Path.cwd() config_default_dir_cwd = Path.cwd()
# Default config directory from default config file # Default config directory from default config file
config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data") 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
# 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 @pytest.fixture
def stash_config_file(config_default_dirs): def server(xprocess, config_eos, 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):
"""Fixture to start the server. """Fixture to start the server.
Provides URL of the server. Provides URL of the server.
""" """
class Starter(ProcessStarter): 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 # assure server to be installed
try: try:
subprocess.run( subprocess.run(
[sys.executable, "-c", "import akkudoktoreos.server.fastapi_server"], [sys.executable, "-c", "import akkudoktoreos.server.fastapi_server"],
check=True, check=True,
env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
@ -154,11 +170,6 @@ def server(xprocess, tmp_path: Path):
# command to start server process # command to start server process
args = [sys.executable, "-m", "akkudoktoreos.server.fastapi_server"] 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 # startup pattern
pattern = "Application startup complete." pattern = "Application startup complete."
@ -174,6 +185,7 @@ def server(xprocess, tmp_path: Path):
# ensure process is running and return its logfile # ensure process is running and return its logfile
pid, logfile = xprocess.ensure("eos", Starter) pid, logfile = xprocess.ensure("eos", Starter)
print(f"View xprocess logfile at: {logfile}")
# create url/port info to the server # create url/port info to the server
url = "http://127.0.0.1:8503" url = "http://127.0.0.1:8503"

View File

@ -3,7 +3,6 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import ( from akkudoktoreos.core.ems import (
EnergieManagementSystem, EnergieManagementSystem,
EnergieManagementSystemParameters, EnergieManagementSystemParameters,
@ -24,10 +23,9 @@ start_hour = 1
# Example initialization of necessary components # Example initialization of necessary components
@pytest.fixture @pytest.fixture
def create_ems_instance() -> EnergieManagementSystem: def create_ems_instance(config_eos) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters.""" """Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values # Assure configuration holds the correct values
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24}) config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
assert config_eos.prediction_hours is not None assert config_eos.prediction_hours is not None

View File

@ -3,7 +3,6 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import ( from akkudoktoreos.core.ems import (
EnergieManagementSystem, EnergieManagementSystem,
EnergieManagementSystemParameters, EnergieManagementSystemParameters,
@ -23,10 +22,9 @@ start_hour = 0
# Example initialization of necessary components # Example initialization of necessary components
@pytest.fixture @pytest.fixture
def create_ems_instance() -> EnergieManagementSystem: def create_ems_instance(config_eos) -> EnergieManagementSystem:
"""Fixture to create an EnergieManagementSystem instance with given test parameters.""" """Fixture to create an EnergieManagementSystem instance with given test parameters."""
# Assure configuration holds the correct values # Assure configuration holds the correct values
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24}) config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 24})
assert config_eos.prediction_hours is not None assert config_eos.prediction_hours is not None

View File

@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest import pytest
from akkudoktoreos.config.config import get_config from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.optimization.genetic import ( from akkudoktoreos.optimization.genetic import (
OptimizationParameters, OptimizationParameters,
OptimizeResponse, 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), ("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.""" """Test optimierung_ems."""
# Assure configuration holds the correct values # Assure configuration holds the correct values
config_eos = get_config()
config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48}) config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48})
# Load input and output data # 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. # The function creates a visualization result PDF as a side-effect.
prepare_visualize_patch.assert_called_once() prepare_visualize_patch.assert_called_once()
assert Path(visualize_filename).exists()

View File

@ -1,43 +1,33 @@
import os
import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest import pytest
from akkudoktoreos.config.config import ConfigEOS, get_config from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.utils.logutil import get_logger from akkudoktoreos.utils.logutil import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
config_eos = get_config()
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") # overwrite config_mixin fixture from conftest
@pytest.fixture(autouse=True)
FILE_TESTDATA_CONFIGEOS_1_JSON = DIR_TESTDATA.joinpath(config_eos.CONFIG_FILE_NAME) def config_mixin():
FILE_TESTDATA_CONFIGEOS_1_DIR = FILE_TESTDATA_CONFIGEOS_1_JSON.parent pass
@pytest.fixture def test_fixture_new_config_file(config_default_dirs):
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):
"""Assure fixture stash_config_file is working.""" """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_user = config_default_dir_user.joinpath(ConfigEOS.CONFIG_FILE_NAME)
config_file_path_cwd = config_default_dir_cwd.joinpath(config_eos.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_user.exists()
assert not config_file_path_cwd.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.""" """Test config constants are the way expected by the tests."""
assert config_eos.APP_NAME == "net.akkudoktor.eos" assert config_eos.APP_NAME == "net.akkudoktor.eos"
assert config_eos.APP_AUTHOR == "akkudoktor" assert config_eos.APP_AUTHOR == "akkudoktor"
@ -46,7 +36,7 @@ def test_config_constants():
assert config_eos.CONFIG_FILE_NAME == "EOS.config.json" 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.""" """Test computed paths for output and cache."""
config_eos.merge_settings_from_dict( 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_output_path == Path("/base/data/output")
assert config_eos.data_cache_path == Path("/base/data/cache") 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.""" """Test that ConfigEOS behaves as a singleton."""
instance1 = ConfigEOS() initial_cfg_file = config_eos.config_file_path
instance2 = ConfigEOS() 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 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.""" """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") 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 == expected_path
assert config_eos.config_default_file_path.is_file() assert config_eos.config_default_file_path.is_file()
def test_config_folder_path(reset_config, config_default_dirs, stash_config_file, monkeypatch): @patch("akkudoktoreos.config.config.user_config_dir")
"""Test that _config_folder_path identifies the correct config directory or None.""" def test_get_config_file_path(user_config_dir_patch, config_eos, config_default_dirs, monkeypatch):
config_default_dir_user, _, _ = config_default_dirs """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 def cfg_file(dir: Path) -> Path:
assert config_eos._config_folder_path() is None return dir.joinpath(ConfigEOS.CONFIG_FILE_NAME)
config_file_user = config_default_dir_user.joinpath(config_eos.CONFIG_FILE_NAME) # Config newly created from fixture with fresh user config directory
shutil.copy2(config_eos.config_default_file_path, config_file_user) assert config_eos._get_config_file_path() == (cfg_file(config_default_dir_user), True)
assert config_eos._config_folder_path() == config_default_dir_user cfg_file(config_default_dir_user).unlink()
monkeypatch.setenv("EOS_DIR", str(FILE_TESTDATA_CONFIGEOS_1_DIR)) with tempfile.TemporaryDirectory() as temp_dir:
assert config_eos._config_folder_path() == FILE_TESTDATA_CONFIGEOS_1_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 monkeypatch.setenv("EOS_CONFIG_DIR", "config")
os.remove(config_file_user) 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.""" """Test if the config is copied to the provided path."""
temp_dir = tempfile.TemporaryDirectory() with tempfile.TemporaryDirectory() as temp_dir:
temp_folder_path = Path(temp_dir.name) temp_folder_path = Path(temp_dir)
temp_config_file_path = temp_folder_path.joinpath(config_eos.CONFIG_FILE_NAME).resolve() temp_config_file_path = temp_folder_path.joinpath(config_eos.CONFIG_FILE_NAME).resolve()
monkeypatch.setenv(config_eos.EOS_DIR, str(temp_folder_path)) monkeypatch.setenv(config_eos.EOS_DIR, str(temp_folder_path))
if temp_config_file_path.exists(): assert not temp_config_file_path.exists()
temp_config_file_path.unlink() with patch("akkudoktoreos.config.config.user_config_dir", return_value=temp_dir):
assert not temp_config_file_path.exists() assert config_eos._get_config_file_path() == (temp_config_file_path, False)
assert config_eos._config_folder_path() is None config_eos.from_config_file()
assert config_eos._config_file_path() == temp_config_file_path assert temp_config_file_path.exists()
config_eos.from_config_file()
assert temp_config_file_path.exists()
# Cleanup after the test
temp_dir.cleanup()

View File

@ -113,7 +113,7 @@ class DerivedDataContainer(DataContainer):
class TestDataBase: class TestDataBase:
@pytest.fixture @pytest.fixture
def base(self, reset_config, monkeypatch): def base(self):
# Provide default values for configuration # Provide default values for configuration
derived = DerivedBase() derived = DerivedBase()
derived.config.update() derived.config.update()

View File

@ -21,8 +21,6 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
"elecpriceforecast_akkudoktor_1.json" "elecpriceforecast_akkudoktor_1.json"
) )
ems_eos = get_ems()
@pytest.fixture @pytest.fixture
def elecprice_provider(monkeypatch): 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) cache_store.clear(clear_all=True)
# Call the method # Call the method
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")) 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) elecprice_provider.update_data(force_enable=True, force_update=True)

View File

@ -3,7 +3,6 @@ from pathlib import Path
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime 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") FILE_TESTDATA_ELECPRICEIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture @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.""" """Fixture to create a ElecPriceProvider instance."""
settings = { settings = {
"elecprice_provider": "ElecPriceImport", "elecprice_provider": "ElecPriceImport",
@ -26,7 +22,7 @@ def elecprice_provider(reset_config, sample_import_1_json):
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
provider = ElecPriceImport() provider = ElecPriceImport()
assert provider.enabled() == True assert provider.enabled()
return provider return provider
@ -49,14 +45,14 @@ def test_singleton_instance(elecprice_provider):
assert elecprice_provider is another_instance 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.""" """Test requesting an unsupported elecprice_provider."""
settings = { settings = {
"elecprice_provider": "<invalid>", "elecprice_provider": "<invalid>",
"elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON), "elecpriceimport_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
} }
config_eos.merge_settings_from_dict(settings) 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) ("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.""" """Test fetching forecast from Import."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file: if from_file:
config_eos.elecpriceimport_json = None config_eos.elecpriceimport_json = None

View File

@ -4,7 +4,6 @@ import numpy as np
import pendulum import pendulum
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.measurement.measurement import MeasurementDataRecord, get_measurement from akkudoktoreos.measurement.measurement import MeasurementDataRecord, get_measurement
from akkudoktoreos.prediction.loadakkudoktor import ( 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 from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture @pytest.fixture
def load_provider(): def load_provider(config_eos):
"""Fixture to initialise the LoadAkkudoktor instance.""" """Fixture to initialise the LoadAkkudoktor instance."""
settings = { settings = {
"load_provider": "LoadAkkudoktor", "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_load_data.return_value = np.random.rand(365, 2, 24)
# Mock methods for updating values # Mock methods for updating values
ems_eos = get_ems()
ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1)) ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1))
# Assure there are no prediction records # Assure there are no prediction records

View File

@ -1,17 +1,20 @@
import json import json
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from generate_openapi import generate_openapi
DIR_PROJECT_ROOT = Path(__file__).parent.parent DIR_PROJECT_ROOT = Path(__file__).parent.parent
DIR_TESTDATA = Path(__file__).parent / "testdata" 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.""" """Verify the openapi spec hasn´t changed."""
old_spec_path = DIR_PROJECT_ROOT / "docs" / "akkudoktoreos" / "openapi.json" old_spec_path = DIR_PROJECT_ROOT / "docs" / "akkudoktoreos" / "openapi.json"
new_spec_path = DIR_TESTDATA / "openapi-new.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: with open(new_spec_path) as f_new:
new_spec = json.load(f_new) new_spec = json.load(f_new)
with open(old_spec_path) as f_old: with open(old_spec_path) as f_old:

View File

@ -1,7 +1,6 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from akkudoktoreos.config.config import get_config
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
@ -19,7 +18,7 @@ from akkudoktoreos.prediction.weatherimport import WeatherImport
@pytest.fixture @pytest.fixture
def sample_settings(reset_config): def sample_settings(config_eos):
"""Fixture that adds settings data to the global config.""" """Fixture that adds settings data to the global config."""
settings = { settings = {
"prediction_hours": 48, "prediction_hours": 48,
@ -33,9 +32,8 @@ def sample_settings(reset_config):
} }
# Merge settings to config # Merge settings to config
config = get_config() config_eos.merge_settings_from_dict(settings)
config.merge_settings_from_dict(settings) return config_eos
return config
@pytest.fixture @pytest.fixture

View File

@ -7,7 +7,6 @@ import pendulum
import pytest import pytest
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.prediction import PredictionCommonSettings from akkudoktoreos.prediction.prediction import PredictionCommonSettings
from akkudoktoreos.prediction.predictionabc import ( from akkudoktoreos.prediction.predictionabc import (
@ -87,7 +86,7 @@ class DerivedPredictionContainer(PredictionContainer):
class TestPredictionBase: class TestPredictionBase:
@pytest.fixture @pytest.fixture
def base(self, reset_config, monkeypatch): def base(self, monkeypatch):
# Provide default values for configuration # Provide default values for configuration
monkeypatch.setenv("latitude", "50.0") monkeypatch.setenv("latitude", "50.0")
monkeypatch.setenv("longitude", "10.0") monkeypatch.setenv("longitude", "10.0")
@ -177,10 +176,11 @@ class TestPredictionProvider:
provider.keep_datetime == expected_keep_datetime provider.keep_datetime == expected_keep_datetime
), "Keep datetime is not calculated correctly." ), "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.""" """Test the `update` method with default parameters."""
# EOS config supersedes # EOS config supersedes
config_eos = get_config()
ems_eos = get_ems() ems_eos = get_ems()
# The following values are currently not set in EOS config, we can override # The following values are currently not set in EOS config, we can override
monkeypatch.setenv("prediction_historic_hours", "2") monkeypatch.setenv("prediction_historic_hours", "2")

View File

@ -4,7 +4,6 @@ from unittest.mock import Mock, patch
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.prediction import get_prediction from akkudoktoreos.prediction.prediction import get_prediction
from akkudoktoreos.prediction.pvforecastakkudoktor import ( 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") FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1.txt")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture @pytest.fixture
def sample_settings(reset_config): def sample_settings(config_eos):
"""Fixture that adds settings data to the global config.""" """Fixture that adds settings data to the global config."""
settings = { settings = {
"prediction_hours": 48, "prediction_hours": 48,
@ -223,6 +218,7 @@ def test_pvforecast_akkudoktor_update_with_sample_forecast(
mock_get.return_value = mock_response mock_get.return_value = mock_response
# Test that update properly inserts data records # Test that update properly inserts data records
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_forecast_start) ems_eos.set_start_datetime(sample_forecast_start)
provider.update_data(force_enable=True, force_update=True) provider.update_data(force_enable=True, force_update=True)
assert compare_datetimes(provider.start_datetime, sample_forecast_start).equal 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 # Report Generation Test
def test_report_ac_power_and_measurement(provider): def test_report_ac_power_and_measurement(provider, config_eos):
# Set the configuration # Set the configuration
config = get_config() config_eos.merge_settings_from_dict(sample_config_data)
config.merge_settings_from_dict(sample_config_data)
record = PVForecastAkkudoktorDataRecord( record = PVForecastAkkudoktorDataRecord(
pvforecastakkudoktor_ac_power_measured=900.0, pvforecastakkudoktor_ac_power_measured=900.0,
@ -275,6 +270,7 @@ def test_timezone_behaviour(
provider.clear() provider.clear()
assert len(provider) == 0 assert len(provider) == 0
ems_eos = get_ems()
ems_eos.set_start_datetime(other_start_datetime) ems_eos.set_start_datetime(other_start_datetime)
provider.update_data(force_update=True) provider.update_data(force_update=True)
assert compare_datetimes(provider.start_datetime, other_start_datetime).equal assert compare_datetimes(provider.start_datetime, other_start_datetime).equal

View File

@ -3,7 +3,6 @@ from pathlib import Path
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime 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") FILE_TESTDATA_PVFORECASTIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture @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.""" """Fixture to create a PVForecastProvider instance."""
settings = { settings = {
"pvforecast_provider": "PVForecastImport", "pvforecast_provider": "PVForecastImport",
@ -26,7 +22,7 @@ def pvforecast_provider(reset_config, sample_import_1_json):
} }
config_eos.merge_settings_from_dict(settings) config_eos.merge_settings_from_dict(settings)
provider = PVForecastImport() provider = PVForecastImport()
assert provider.enabled() == True assert provider.enabled()
return provider return provider
@ -49,14 +45,14 @@ def test_singleton_instance(pvforecast_provider):
assert pvforecast_provider is another_instance 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.""" """Test requesting an unsupported pvforecast_provider."""
settings = { settings = {
"pvforecast_provider": "<invalid>", "pvforecast_provider": "<invalid>",
"pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON), "pvforecastimport_file_path": str(FILE_TESTDATA_PVFORECASTIMPORT_1_JSON),
} }
config_eos.merge_settings_from_dict(settings) 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) ("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.""" """Test fetching forecast from import."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file: if from_file:
config_eos.pvforecastimport_json = None config_eos.pvforecastimport_json = None

View File

@ -2,12 +2,8 @@ from http import HTTPStatus
import requests import requests
from akkudoktoreos.config.config import get_config
config_eos = get_config() def test_server(server, config_eos):
def test_server(server):
"""Test the server.""" """Test the server."""
# validate correct path in server # validate correct path in server
assert config_eos.data_folder_path is not None assert config_eos.data_folder_path is not None

View File

@ -1,32 +1,28 @@
import os
from pathlib import Path from pathlib import Path
from matplotlib.testing.compare import compare_images from matplotlib.testing.compare import compare_images
from akkudoktoreos.config.config import get_config
from akkudoktoreos.utils.visualize import generate_example_report from akkudoktoreos.utils.visualize import generate_example_report
filename = "example_report.pdf" 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" DIR_TESTDATA = Path(__file__).parent / "testdata"
reference_file = DIR_TESTDATA / "test_example_report.pdf" 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.""" """Test generation of example visualization report."""
# Delete the old generated file if it exists output_dir = config_eos.data_output_path
if os.path.isfile(output_file): assert output_dir is not None
os.remove(output_file) output_file = output_dir / filename
assert not output_file.exists()
generate_example_report(filename) # Generate PDF
generate_example_report()
# Check if the file exists # Check if the file exists
assert os.path.isfile(output_file) assert output_file.exists()
# Compare the generated file with the reference file # Compare the generated file with the reference file
comparison = compare_images(str(reference_file), str(output_file), tol=0) comparison = compare_images(str(reference_file), str(output_file), tol=0)

View File

@ -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_1_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_1.json")
FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_2.json") FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_brightsky_2.json")
ems_eos = get_ems()
@pytest.fixture @pytest.fixture
def weather_provider(monkeypatch): def weather_provider(monkeypatch):
@ -64,7 +62,7 @@ def test_invalid_provider(weather_provider, monkeypatch):
"""Test requesting an unsupported weather_provider.""" """Test requesting an unsupported weather_provider."""
monkeypatch.setenv("weather_provider", "<invalid>") monkeypatch.setenv("weather_provider", "<invalid>")
weather_provider.config.update() weather_provider.config.update()
assert weather_provider.enabled() == False assert not weather_provider.enabled()
def test_invalid_coordinates(weather_provider, monkeypatch): 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) cache_store.clear(clear_all=True)
# Call the method # Call the method
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin")) 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) weather_provider.update_data(force_enable=True, force_update=True)

View File

@ -9,7 +9,6 @@ import pvlib
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.utils.cacheutil import CacheFileStore 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_HTML = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.html")
FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.json") FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA = DIR_TESTDATA.joinpath("weatherforecast_clearout_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture @pytest.fixture
def weather_provider(): def weather_provider(config_eos):
"""Fixture to create a WeatherProvider instance.""" """Fixture to create a WeatherProvider instance."""
settings = { settings = {
"weather_provider": "ClearOutside", "weather_provider": "ClearOutside",
@ -70,16 +66,16 @@ def test_singleton_instance(weather_provider):
assert weather_provider is another_instance 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.""" """Test requesting an unsupported weather_provider."""
settings = { settings = {
"weather_provider": "<invalid>", "weather_provider": "<invalid>",
} }
config_eos.merge_settings_from_dict(settings) 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.""" """Test invalid coordinates raise ValueError."""
settings = { settings = {
"weather_provider": "ClearOutside", "weather_provider": "ClearOutside",
@ -118,7 +114,7 @@ def test_irridiance_estimate_from_cloud_cover(weather_provider):
@patch("requests.get") @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.""" """Test fetching forecast from ClearOutside."""
# Mock response object # Mock response object
mock_response = Mock() 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") expected_keep = to_datetime("2024-10-24 00:00:00", in_timezone="Europe/Berlin")
# Call the method # Call the method
ems_eos = get_ems()
ems_eos.set_start_datetime(expected_start) ems_eos.set_start_datetime(expected_start)
weather_provider.update_data() weather_provider.update_data()

View File

@ -3,7 +3,6 @@ from pathlib import Path
import pytest import pytest
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
from akkudoktoreos.prediction.weatherimport import WeatherImport from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime 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") FILE_TESTDATA_WEATHERIMPORT_1_JSON = DIR_TESTDATA.joinpath("import_input_1.json")
config_eos = get_config()
ems_eos = get_ems()
@pytest.fixture @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.""" """Fixture to create a WeatherProvider instance."""
settings = { settings = {
"weather_provider": "WeatherImport", "weather_provider": "WeatherImport",
@ -49,7 +45,7 @@ def test_singleton_instance(weather_provider):
assert weather_provider is another_instance 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.""" """Test requesting an unsupported weather_provider."""
settings = { settings = {
"weather_provider": "<invalid>", "weather_provider": "<invalid>",
@ -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) ("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.""" """Test fetching forecast from Import."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin")) ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file: if from_file:
config_eos.weatherimport_json = None config_eos.weatherimport_json = None