mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-10-30 22:36:21 +00:00 
			
		
		
		
	Nested config, devices registry
* All config now nested.
    - Use default config from model field default values. If providers
      should be enabled by default, non-empty default config file could
      be provided again.
    - Environment variable support with EOS_ prefix and __ between levels,
      e.g. EOS_SERVER__EOS_SERVER_PORT=8503 where all values are case
      insensitive.
      For more information see:
      https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
    - Use devices as registry for configured devices. DeviceBase as base
      class with for now just initializion support (in the future expand
      to operations during optimization).
    - Strip down ConfigEOS to the only configuration instance. Reload
      from file or reset to defaults is possible.
 * Fix multi-initialization of derived SingletonMixin classes.
			
			
This commit is contained in:
		| @@ -12,31 +12,34 @@ Key features: | ||||
| import os | ||||
| import shutil | ||||
| from pathlib import Path | ||||
| from typing import Any, ClassVar, List, Optional | ||||
| from typing import Any, ClassVar, Optional, Type | ||||
|  | ||||
| from platformdirs import user_config_dir, user_data_dir | ||||
| from pydantic import Field, ValidationError, computed_field | ||||
| from pydantic import Field, computed_field | ||||
| from pydantic_settings import ( | ||||
|     BaseSettings, | ||||
|     JsonConfigSettingsSource, | ||||
|     PydanticBaseSettingsSource, | ||||
|     SettingsConfigDict, | ||||
| ) | ||||
| from pydantic_settings.sources import ConfigFileSourceMixin | ||||
|  | ||||
| # settings | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.coreabc import SingletonMixin | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.core.logsettings import LoggingCommonSettings | ||||
| from akkudoktoreos.devices.devices import DevicesCommonSettings | ||||
| from akkudoktoreos.core.pydantic import merge_models | ||||
| from akkudoktoreos.devices.settings import DevicesCommonSettings | ||||
| from akkudoktoreos.measurement.measurement import MeasurementCommonSettings | ||||
| from akkudoktoreos.optimization.optimization import OptimizationCommonSettings | ||||
| from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings | ||||
| from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings | ||||
| from akkudoktoreos.prediction.load import LoadCommonSettings | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings | ||||
| from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings | ||||
| from akkudoktoreos.prediction.prediction import PredictionCommonSettings | ||||
| from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings | ||||
| from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings | ||||
| from akkudoktoreos.prediction.weather import WeatherCommonSettings | ||||
| from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings | ||||
| from akkudoktoreos.server.server import ServerCommonSettings | ||||
| from akkudoktoreos.utils.utils import UtilsCommonSettings | ||||
| from akkudoktoreos.utils.utils import UtilsCommonSettings, classproperty | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| @@ -67,11 +70,11 @@ class ConfigCommonSettings(SettingsBaseModel): | ||||
|     ) | ||||
|  | ||||
|     data_output_subpath: Optional[Path] = Field( | ||||
|         "output", description="Sub-path for the EOS output data directory." | ||||
|         default="output", description="Sub-path for the EOS output data directory." | ||||
|     ) | ||||
|  | ||||
|     data_cache_subpath: Optional[Path] = Field( | ||||
|         "cache", description="Sub-path for the EOS cache data directory." | ||||
|         default="cache", description="Sub-path for the EOS cache data directory." | ||||
|     ) | ||||
|  | ||||
|     # Computed fields | ||||
| @@ -89,31 +92,51 @@ class ConfigCommonSettings(SettingsBaseModel): | ||||
|         return get_absolute_path(self.data_folder_path, self.data_cache_subpath) | ||||
|  | ||||
|  | ||||
| class SettingsEOS( | ||||
|     ConfigCommonSettings, | ||||
|     LoggingCommonSettings, | ||||
|     DevicesCommonSettings, | ||||
|     MeasurementCommonSettings, | ||||
|     OptimizationCommonSettings, | ||||
|     PredictionCommonSettings, | ||||
|     ElecPriceCommonSettings, | ||||
|     ElecPriceImportCommonSettings, | ||||
|     LoadCommonSettings, | ||||
|     LoadAkkudoktorCommonSettings, | ||||
|     LoadImportCommonSettings, | ||||
|     PVForecastCommonSettings, | ||||
|     PVForecastImportCommonSettings, | ||||
|     WeatherCommonSettings, | ||||
|     WeatherImportCommonSettings, | ||||
|     ServerCommonSettings, | ||||
|     UtilsCommonSettings, | ||||
| ): | ||||
|     """Settings for all EOS.""" | ||||
| class SettingsEOS(BaseSettings): | ||||
|     """Settings for all EOS. | ||||
|  | ||||
|     pass | ||||
|     Used by updating the configuration with specific settings only. | ||||
|     """ | ||||
|  | ||||
|     general: Optional[ConfigCommonSettings] = None | ||||
|     logging: Optional[LoggingCommonSettings] = None | ||||
|     devices: Optional[DevicesCommonSettings] = None | ||||
|     measurement: Optional[MeasurementCommonSettings] = None | ||||
|     optimization: Optional[OptimizationCommonSettings] = None | ||||
|     prediction: Optional[PredictionCommonSettings] = None | ||||
|     elecprice: Optional[ElecPriceCommonSettings] = None | ||||
|     load: Optional[LoadCommonSettings] = None | ||||
|     pvforecast: Optional[PVForecastCommonSettings] = None | ||||
|     weather: Optional[WeatherCommonSettings] = None | ||||
|     server: Optional[ServerCommonSettings] = None | ||||
|     utils: Optional[UtilsCommonSettings] = None | ||||
|  | ||||
|     model_config = SettingsConfigDict( | ||||
|         env_nested_delimiter="__", nested_model_default_partial_update=True, env_prefix="EOS_" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class ConfigEOS(SingletonMixin, SettingsEOS): | ||||
| class SettingsEOSDefaults(SettingsEOS): | ||||
|     """Settings for all of EOS with defaults. | ||||
|  | ||||
|     Used by ConfigEOS instance to make all fields available. | ||||
|     """ | ||||
|  | ||||
|     general: ConfigCommonSettings = ConfigCommonSettings() | ||||
|     logging: LoggingCommonSettings = LoggingCommonSettings() | ||||
|     devices: DevicesCommonSettings = DevicesCommonSettings() | ||||
|     measurement: MeasurementCommonSettings = MeasurementCommonSettings() | ||||
|     optimization: OptimizationCommonSettings = OptimizationCommonSettings() | ||||
|     prediction: PredictionCommonSettings = PredictionCommonSettings() | ||||
|     elecprice: ElecPriceCommonSettings = ElecPriceCommonSettings() | ||||
|     load: LoadCommonSettings = LoadCommonSettings() | ||||
|     pvforecast: PVForecastCommonSettings = PVForecastCommonSettings() | ||||
|     weather: WeatherCommonSettings = WeatherCommonSettings() | ||||
|     server: ServerCommonSettings = ServerCommonSettings() | ||||
|     utils: UtilsCommonSettings = UtilsCommonSettings() | ||||
|  | ||||
|  | ||||
| class ConfigEOS(SingletonMixin, SettingsEOSDefaults): | ||||
|     """Singleton configuration handler for the EOS application. | ||||
|  | ||||
|     ConfigEOS extends `SettingsEOS` with support for  default configuration paths and automatic | ||||
| @@ -143,8 +166,6 @@ class ConfigEOS(SingletonMixin, SettingsEOS): | ||||
|         in one part of the application reflects across all references to this class. | ||||
|  | ||||
|     Attributes: | ||||
|         _settings (ClassVar[SettingsEOS]): Holds application-wide settings. | ||||
|         _file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file. | ||||
|         config_folder_path (Optional[Path]): Path to the configuration directory. | ||||
|         config_file_path (Optional[Path]): Path to the configuration file. | ||||
|  | ||||
| @@ -155,7 +176,7 @@ class ConfigEOS(SingletonMixin, SettingsEOS): | ||||
|         To initialize and access configuration attributes (only one instance is created): | ||||
|         ```python | ||||
|         config_eos = ConfigEOS()  # Always returns the same instance | ||||
|         print(config_eos.prediction_hours)  # Access a setting from the loaded configuration | ||||
|         print(config_eos.prediction.prediction_hours)  # Access a setting from the loaded configuration | ||||
|         ``` | ||||
|  | ||||
|     """ | ||||
| @@ -167,111 +188,126 @@ class ConfigEOS(SingletonMixin, SettingsEOS): | ||||
|     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: ClassVar[Optional[Path]] = None | ||||
|     _config_file_path: ClassVar[Optional[Path]] = None | ||||
|  | ||||
|     _config_folder_path: Optional[Path] = None | ||||
|     _config_file_path: Optional[Path] = None | ||||
|     @classmethod | ||||
|     def settings_customise_sources( | ||||
|         cls, | ||||
|         settings_cls: Type[BaseSettings], | ||||
|         init_settings: PydanticBaseSettingsSource, | ||||
|         env_settings: PydanticBaseSettingsSource, | ||||
|         dotenv_settings: PydanticBaseSettingsSource, | ||||
|         file_secret_settings: PydanticBaseSettingsSource, | ||||
|     ) -> tuple[PydanticBaseSettingsSource, ...]: | ||||
|         """Customizes the order and handling of settings sources for a Pydantic BaseSettings subclass. | ||||
|  | ||||
|         This method determines the sources for application configuration settings, including | ||||
|         environment variables, dotenv files, JSON configuration files, and file secrets. | ||||
|         It ensures that a default configuration file exists and creates one if necessary. | ||||
|  | ||||
|         Args: | ||||
|             settings_cls (Type[BaseSettings]): The Pydantic BaseSettings class for which sources are customized. | ||||
|             init_settings (PydanticBaseSettingsSource): The initial settings source, typically passed at runtime. | ||||
|             env_settings (PydanticBaseSettingsSource): Settings sourced from environment variables. | ||||
|             dotenv_settings (PydanticBaseSettingsSource): Settings sourced from a dotenv file. | ||||
|             file_secret_settings (PydanticBaseSettingsSource): Settings sourced from secret files. | ||||
|  | ||||
|         Returns: | ||||
|             tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied. | ||||
|  | ||||
|         Behavior: | ||||
|             1. Checks for the existence of a JSON configuration file in the expected location. | ||||
|             2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a | ||||
|                default configuration file to the location. If the copy fails, uses the default configuration file directly. | ||||
|             3. Creates a `JsonConfigSettingsSource` for both the configuration file and the default configuration file. | ||||
|             4. Updates class attributes `_config_folder_path` and `_config_file_path` to reflect the determined paths. | ||||
|             5. Returns a tuple containing all provided and newly created settings sources in the desired order. | ||||
|  | ||||
|         Notes: | ||||
|             - This method logs a warning if the default configuration file cannot be copied. | ||||
|             - It ensures that a fallback to the default configuration file is always possible. | ||||
|         """ | ||||
|         file_settings: Optional[ConfigFileSourceMixin] = None | ||||
|         config_file, exists = cls._get_config_file_path() | ||||
|         config_dir = config_file.parent | ||||
|         if not exists: | ||||
|             config_dir.mkdir(parents=True, exist_ok=True) | ||||
|             try: | ||||
|                 shutil.copy2(cls.config_default_file_path, config_file) | ||||
|             except Exception as exc: | ||||
|                 logger.warning(f"Could not copy default config: {exc}. Using default config...") | ||||
|                 config_file = cls.config_default_file_path | ||||
|                 config_dir = config_file.parent | ||||
|         file_settings = JsonConfigSettingsSource(settings_cls, json_file=config_file) | ||||
|         default_settings = JsonConfigSettingsSource( | ||||
|             settings_cls, json_file=cls.config_default_file_path | ||||
|         ) | ||||
|         cls._config_folder_path = config_dir | ||||
|         cls._config_file_path = config_file | ||||
|  | ||||
|         return ( | ||||
|             init_settings, | ||||
|             env_settings, | ||||
|             dotenv_settings, | ||||
|             file_settings, | ||||
|             file_secret_settings, | ||||
|             default_settings, | ||||
|         ) | ||||
|  | ||||
|     # 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: | ||||
|     @classmethod | ||||
|     @classproperty | ||||
|     def config_default_file_path(cls) -> Path: | ||||
|         """Compute the default config file path.""" | ||||
|         return self.package_root_path.joinpath("data/default.config.json") | ||||
|         return cls.package_root_path.joinpath("data/default.config.json") | ||||
|  | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def package_root_path(self) -> Path: | ||||
|     @classmethod | ||||
|     @classproperty | ||||
|     def package_root_path(cls) -> Path: | ||||
|         """Compute the package root path.""" | ||||
|         return Path(__file__).parent.parent.resolve() | ||||
|  | ||||
|     # Computed fields | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def config_keys(self) -> List[str]: | ||||
|         """Returns the keys of all fields in the configuration.""" | ||||
|         key_list = [] | ||||
|         key_list.extend(list(self.model_fields.keys())) | ||||
|         key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys())) | ||||
|         return key_list | ||||
|  | ||||
|     # Computed fields | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def config_keys_read_only(self) -> List[str]: | ||||
|         """Returns the keys of all read only fields in the configuration.""" | ||||
|         key_list = [] | ||||
|         key_list.extend(list(self.__pydantic_decorators__.computed_fields.keys())) | ||||
|         return key_list | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         """Initializes the singleton ConfigEOS instance. | ||||
|  | ||||
|         Configuration data is loaded from a configuration file or a default one is created if none | ||||
|         exists. | ||||
|         """ | ||||
|         super().__init__() | ||||
|         self.from_config_file() | ||||
|         self.update() | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self._create_initial_config_file() | ||||
|         self._update_data_folder_path() | ||||
|  | ||||
|     @property | ||||
|     def settings(self) -> Optional[SettingsEOS]: | ||||
|         """Returns global settings for EOS. | ||||
|     def _setup(self, *args: Any, **kwargs: Any) -> None: | ||||
|         """Re-initialize global settings.""" | ||||
|         SettingsEOSDefaults.__init__(self, *args, **kwargs) | ||||
|         self._create_initial_config_file() | ||||
|         self._update_data_folder_path() | ||||
|  | ||||
|         Settings generally provide configuration for EOS and are typically set only once. | ||||
|  | ||||
|         Returns: | ||||
|             SettingsEOS: The settings for EOS or None. | ||||
|         """ | ||||
|         return ConfigEOS._settings | ||||
|  | ||||
|     @classmethod | ||||
|     def _merge_and_update_settings(cls, settings: SettingsEOS) -> None: | ||||
|         """Merge new and available settings. | ||||
|  | ||||
|         Args: | ||||
|             settings (SettingsEOS): The new settings to apply. | ||||
|         """ | ||||
|         for key in SettingsEOS.model_fields: | ||||
|             if value := getattr(settings, key, None): | ||||
|                 setattr(cls._settings, key, value) | ||||
|  | ||||
|     def merge_settings(self, settings: SettingsEOS, force: Optional[bool] = None) -> None: | ||||
|     def merge_settings(self, settings: SettingsEOS) -> None: | ||||
|         """Merges the provided settings into the global settings for EOS, with optional overwrite. | ||||
|  | ||||
|         Args: | ||||
|             settings (SettingsEOS): The settings to apply globally. | ||||
|             force (Optional[bool]): If True, overwrites the existing settings completely. | ||||
|                 If False, the new settings are merged to the existing ones with priority for | ||||
|                 the new ones. Defaults to False. | ||||
|  | ||||
|         Raises: | ||||
|             ValueError: If settings are already set and `force` is not True or | ||||
|                 if the `settings` is not a `SettingsEOS` instance. | ||||
|             ValueError: If the `settings` is not a `SettingsEOS` instance. | ||||
|         """ | ||||
|         if not isinstance(settings, SettingsEOS): | ||||
|             raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.") | ||||
|  | ||||
|         if ConfigEOS._settings is None or force: | ||||
|             ConfigEOS._settings = settings | ||||
|         else: | ||||
|             self._merge_and_update_settings(settings) | ||||
|  | ||||
|         # Update configuration after merging | ||||
|         self.update() | ||||
|         self.merge_settings_from_dict(settings.model_dump()) | ||||
|  | ||||
|     def merge_settings_from_dict(self, data: dict) -> None: | ||||
|         """Merges the provided dictionary data into the current instance. | ||||
| @@ -289,141 +325,78 @@ class ConfigEOS(SingletonMixin, SettingsEOS): | ||||
|  | ||||
|         Example: | ||||
|             >>> config = get_config() | ||||
|             >>> new_data = {"prediction_hours": 24, "server_eos_port": 8000} | ||||
|             >>> new_data = {"prediction": {"prediction_hours": 24}, "server": {"server_eos_port": 8000}} | ||||
|             >>> config.merge_settings_from_dict(new_data) | ||||
|         """ | ||||
|         # Create new settings instance with reset optional fields and merged data | ||||
|         settings = SettingsEOS.from_dict(data) | ||||
|         self.merge_settings(settings) | ||||
|         self._setup(**merge_models(self, data)) | ||||
|  | ||||
|     def reset_settings(self) -> None: | ||||
|         """Reset all available settings. | ||||
|         """Reset all changed settings to environment/config file defaults. | ||||
|  | ||||
|         This functions basically deletes the settings provided before. | ||||
|         """ | ||||
|         ConfigEOS._settings = None | ||||
|         self._setup() | ||||
|  | ||||
|     def _create_initial_config_file(self) -> None: | ||||
|         if self.config_file_path is not None and not self.config_file_path.exists(): | ||||
|             self.config_file_path.parent.mkdir(parents=True, exist_ok=True) | ||||
|             with open(self.config_file_path, "w") as f: | ||||
|                 f.write(self.model_dump_json(indent=4)) | ||||
|  | ||||
|     def _update_data_folder_path(self) -> None: | ||||
|         """Updates path to the data directory.""" | ||||
|         # From Settings | ||||
|         if self.settings and (data_dir := self.settings.data_folder_path): | ||||
|         if data_dir := self.general.data_folder_path: | ||||
|             try: | ||||
|                 data_dir.mkdir(parents=True, exist_ok=True) | ||||
|                 self.data_folder_path = data_dir | ||||
|                 self.general.data_folder_path = data_dir | ||||
|                 return | ||||
|             except: | ||||
|                 pass | ||||
|             except Exception as e: | ||||
|                 logger.warning(f"Could not setup data dir: {e}") | ||||
|         # From EOS_DIR env | ||||
|         env_dir = os.getenv(self.EOS_DIR) | ||||
|         if env_dir is not None: | ||||
|         if env_dir := os.getenv(self.EOS_DIR): | ||||
|             try: | ||||
|                 data_dir = Path(env_dir).resolve() | ||||
|                 data_dir.mkdir(parents=True, exist_ok=True) | ||||
|                 self.data_folder_path = data_dir | ||||
|                 self.general.data_folder_path = data_dir | ||||
|                 return | ||||
|             except: | ||||
|                 pass | ||||
|         # From configuration file | ||||
|         if self._file_settings and (data_dir := self._file_settings.data_folder_path): | ||||
|             try: | ||||
|                 data_dir.mkdir(parents=True, exist_ok=True) | ||||
|                 self.data_folder_path = data_dir | ||||
|                 return | ||||
|             except: | ||||
|                 pass | ||||
|             except Exception as e: | ||||
|                 logger.warning(f"Could not setup data dir: {e}") | ||||
|         # From platform specific default path | ||||
|         try: | ||||
|             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 | ||||
|                 self.general.data_folder_path = data_dir | ||||
|                 return | ||||
|         except: | ||||
|             pass | ||||
|         except Exception as e: | ||||
|             logger.warning(f"Could not setup data dir: {e}") | ||||
|         # Current working directory | ||||
|         data_dir = Path.cwd() | ||||
|         self.data_folder_path = data_dir | ||||
|         self.general.data_folder_path = data_dir | ||||
|  | ||||
|     def _get_config_file_path(self) -> tuple[Path, bool]: | ||||
|     @classmethod | ||||
|     def _get_config_file_path(cls) -> tuple[Path, bool]: | ||||
|         """Finds the a valid configuration file or returns the desired path for a new config file. | ||||
|  | ||||
|         Returns: | ||||
|             tuple[Path, bool]: The path to the configuration directory and if there is already a config file there | ||||
|         """ | ||||
|         config_dirs = [] | ||||
|         env_base_dir = os.getenv(self.EOS_DIR) | ||||
|         env_config_dir = os.getenv(self.EOS_CONFIG_DIR) | ||||
|         env_base_dir = os.getenv(cls.EOS_DIR) | ||||
|         env_config_dir = os.getenv(cls.EOS_CONFIG_DIR) | ||||
|         env_dir = get_absolute_path(env_base_dir, env_config_dir) | ||||
|         logger.debug(f"Envionment config dir: '{env_dir}'") | ||||
|         logger.debug(f"Environment config dir: '{env_dir}'") | ||||
|         if env_dir is not None: | ||||
|             config_dirs.append(env_dir.resolve()) | ||||
|         config_dirs.append(Path(user_config_dir(self.APP_NAME))) | ||||
|         config_dirs.append(Path(user_config_dir(cls.APP_NAME))) | ||||
|         config_dirs.append(Path.cwd()) | ||||
|         for cdir in config_dirs: | ||||
|             cfile = cdir.joinpath(self.CONFIG_FILE_NAME) | ||||
|             cfile = cdir.joinpath(cls.CONFIG_FILE_NAME) | ||||
|             if cfile.exists(): | ||||
|                 logger.debug(f"Found config file: '{cfile}'") | ||||
|                 return cfile, True | ||||
|         return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False | ||||
|  | ||||
|     def settings_from_config_file(self) -> tuple[SettingsEOS, Path]: | ||||
|         """Load settings from the configuration file. | ||||
|  | ||||
|         If the config file does not exist, it will be created. | ||||
|  | ||||
|         Returns: | ||||
|             tuple of settings and path | ||||
|             settings (SettingsEOS): The settings defined by the EOS configuration file. | ||||
|             path (pathlib.Path): The path of the configuration file. | ||||
|  | ||||
|         Raises: | ||||
|             ValueError: If the configuration file is invalid or incomplete. | ||||
|         """ | ||||
|         config_file, exists = self._get_config_file_path() | ||||
|         config_dir = config_file.parent | ||||
|  | ||||
|         # Create config directory and copy default config if file does not exist | ||||
|         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 config...") | ||||
|                 config_file = self.config_default_file_path | ||||
|                 config_dir = config_file.parent | ||||
|  | ||||
|         # Load and validate the configuration file | ||||
|         with config_file.open("r", encoding=self.ENCODING) as f_in: | ||||
|             try: | ||||
|                 json_str = f_in.read() | ||||
|                 settings = SettingsEOS.model_validate_json(json_str) | ||||
|             except ValidationError as exc: | ||||
|                 raise ValueError(f"Configuration '{config_file}' is incomplete or not valid: {exc}") | ||||
|  | ||||
|         return settings, config_file | ||||
|  | ||||
|     def from_config_file(self) -> tuple[SettingsEOS, Path]: | ||||
|         """Load the configuration file settings for EOS. | ||||
|  | ||||
|         Returns: | ||||
|             tuple of settings and path | ||||
|             settings (SettingsEOS): The settings defined by the EOS configuration file. | ||||
|             path (pathlib.Path): The path of the configuration file. | ||||
|  | ||||
|         Raises: | ||||
|             ValueError: If the configuration file is invalid or incomplete. | ||||
|         """ | ||||
|         # Load settings from config file | ||||
|         ConfigEOS._file_settings, config_file = self.settings_from_config_file() | ||||
|  | ||||
|         # Update configuration in memory | ||||
|         self.update() | ||||
|  | ||||
|         # Everything worked, remember the values | ||||
|         self._config_folder_path = config_file.parent | ||||
|         self._config_file_path = config_file | ||||
|  | ||||
|         return ConfigEOS._file_settings, config_file | ||||
|         return config_dirs[0].joinpath(cls.CONFIG_FILE_NAME), False | ||||
|  | ||||
|     def to_config_file(self) -> None: | ||||
|         """Saves the current configuration to the configuration file. | ||||
| @@ -436,74 +409,21 @@ class ConfigEOS(SingletonMixin, SettingsEOS): | ||||
|         if not self.config_file_path: | ||||
|             raise ValueError("Configuration file path unknown.") | ||||
|         with self.config_file_path.open("w", encoding=self.ENCODING) as f_out: | ||||
|             try: | ||||
|                 json_str = super().to_json() | ||||
|                 # Write to file | ||||
|                 f_out.write(json_str) | ||||
|                 # Also remember as actual settings | ||||
|                 ConfigEOS._file_settings = SettingsEOS.model_validate_json(json_str) | ||||
|             except ValidationError as exc: | ||||
|                 raise ValueError(f"Could not update '{self.config_file_path}': {exc}") | ||||
|  | ||||
|     def _config_value(self, key: str) -> Any: | ||||
|         """Retrieves the configuration value for a specific key, following a priority order. | ||||
|  | ||||
|         Values are fetched in the following order: | ||||
|             1. Settings. | ||||
|             2. Environment variables. | ||||
|             3. EOS configuration file. | ||||
|             4. Current configuration. | ||||
|             5. Field default constants. | ||||
|  | ||||
|         Args: | ||||
|             key (str): The configuration key to retrieve. | ||||
|  | ||||
|         Returns: | ||||
|             Any: The configuration value, or None if not found. | ||||
|         """ | ||||
|         # Settings | ||||
|         if ConfigEOS._settings: | ||||
|             if (value := getattr(self.settings, key, None)) is not None: | ||||
|                 return value | ||||
|  | ||||
|         # Environment variables | ||||
|         if (value := os.getenv(key)) is not None: | ||||
|             try: | ||||
|                 return float(value) | ||||
|             except ValueError: | ||||
|                 return value | ||||
|  | ||||
|         # EOS configuration file. | ||||
|         if self._file_settings: | ||||
|             if (value := getattr(self._file_settings, key, None)) is not None: | ||||
|                 return value | ||||
|  | ||||
|         # Current configuration - key is valid as called by update(). | ||||
|         if (value := getattr(self, key, None)) is not None: | ||||
|             return value | ||||
|  | ||||
|         # Field default constants | ||||
|         if (value := ConfigEOS.model_fields[key].default) is not None: | ||||
|             return value | ||||
|  | ||||
|         logger.debug(f"Value for configuration key '{key}' not found or is {value}") | ||||
|         return None | ||||
|             json_str = super().model_dump_json() | ||||
|             f_out.write(json_str) | ||||
|  | ||||
|     def update(self) -> None: | ||||
|         """Updates all configuration fields. | ||||
|  | ||||
|         This method updates all configuration fields using the following order for value retrieval: | ||||
|             1. Settings. | ||||
|             1. Current settings. | ||||
|             2. Environment variables. | ||||
|             3. EOS configuration file. | ||||
|             4. Current configuration. | ||||
|             5. Field default constants. | ||||
|             4. Field default constants. | ||||
|  | ||||
|         The first non None value in priority order is taken. | ||||
|         """ | ||||
|         self._update_data_folder_path() | ||||
|         for key in self.model_fields: | ||||
|             setattr(self, key, self._config_value(key)) | ||||
|         self._setup(**self.model_dump()) | ||||
|  | ||||
|  | ||||
| def get_config() -> ConfigEOS: | ||||
|   | ||||
| @@ -4,10 +4,6 @@ from akkudoktoreos.core.pydantic import PydanticBaseModel | ||||
|  | ||||
|  | ||||
| class SettingsBaseModel(PydanticBaseModel): | ||||
|     """Base model class for all settings configurations. | ||||
|  | ||||
|     Note: | ||||
|         Settings property names shall be disjunctive to all existing settings' property names. | ||||
|     """ | ||||
|     """Base model class for all settings configurations.""" | ||||
|  | ||||
|     pass | ||||
|   | ||||
| @@ -265,6 +265,12 @@ class SingletonMixin: | ||||
|         class MySingletonModel(SingletonMixin, PydanticBaseModel): | ||||
|             name: str | ||||
|  | ||||
|             # implement __init__ to avoid re-initialization of parent class PydanticBaseModel: | ||||
|             def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|                 if hasattr(self, "_initialized"): | ||||
|                     return | ||||
|                 super().__init__(*args, **kwargs) | ||||
|  | ||||
|         instance1 = MySingletonModel(name="Instance 1") | ||||
|         instance2 = MySingletonModel(name="Instance 2") | ||||
|  | ||||
|   | ||||
| @@ -1110,7 +1110,7 @@ class DataProvider(SingletonMixin, DataSequence): | ||||
|  | ||||
|         To be implemented by derived classes. | ||||
|         """ | ||||
|         return self.provider_id() == self.config.abstract_provider | ||||
|         raise NotImplementedError() | ||||
|  | ||||
|     @abstractmethod | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
| @@ -1121,6 +1121,11 @@ class DataProvider(SingletonMixin, DataSequence): | ||||
|         """ | ||||
|         pass | ||||
|  | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def update_data( | ||||
|         self, | ||||
|         force_enable: Optional[bool] = False, | ||||
| @@ -1595,6 +1600,11 @@ class DataContainer(SingletonMixin, DataBase, MutableMapping): | ||||
|         ) | ||||
|         return list(key_set) | ||||
|  | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def __getitem__(self, key: str) -> pd.Series: | ||||
|         """Retrieve a Pandas Series for a specified key from the data in each DataProvider. | ||||
|  | ||||
|   | ||||
| @@ -169,6 +169,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda | ||||
|     dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") | ||||
|     ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(default=None, description="TBD") | ||||
|  | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def set_parameters( | ||||
|         self, | ||||
|         parameters: EnergieManagementSystemParameters, | ||||
| @@ -193,9 +198,9 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda | ||||
|         self.ev = ev | ||||
|         self.home_appliance = home_appliance | ||||
|         self.inverter = inverter | ||||
|         self.ac_charge_hours = np.full(self.config.prediction_hours, 0.0) | ||||
|         self.dc_charge_hours = np.full(self.config.prediction_hours, 1.0) | ||||
|         self.ev_charge_hours = np.full(self.config.prediction_hours, 0.0) | ||||
|         self.ac_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0) | ||||
|         self.dc_charge_hours = np.full(self.config.prediction.prediction_hours, 1.0) | ||||
|         self.ev_charge_hours = np.full(self.config.prediction.prediction_hours, 0.0) | ||||
|  | ||||
|     def set_akku_discharge_hours(self, ds: np.ndarray) -> None: | ||||
|         if self.battery is not None: | ||||
| @@ -246,11 +251,11 @@ class EnergieManagementSystem(SingletonMixin, ConfigMixin, PredictionMixin, Pyda | ||||
|             error_msg = "Start datetime unknown." | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|         if self.config.prediction_hours is None: | ||||
|         if self.config.prediction.prediction_hours is None: | ||||
|             error_msg = "Prediction hours unknown." | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|         if self.config.optimisation_hours is None: | ||||
|         if self.config.prediction.optimisation_hours is None: | ||||
|             error_msg = "Optimisation hours unknown." | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|   | ||||
| @@ -35,6 +35,21 @@ from pydantic import ( | ||||
| from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration | ||||
|  | ||||
|  | ||||
| def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]: | ||||
|     def deep_update(source_dict: dict[str, Any], update_dict: dict[str, Any]) -> dict[str, Any]: | ||||
|         for key, value in source_dict.items(): | ||||
|             if isinstance(value, dict) and isinstance(update_dict.get(key), dict): | ||||
|                 update_dict[key] = deep_update(update_dict[key], value) | ||||
|             else: | ||||
|                 update_dict[key] = value | ||||
|         return update_dict | ||||
|  | ||||
|     source_dict = source.model_dump(exclude_unset=True) | ||||
|     merged_dict = deep_update(source_dict, update_dict) | ||||
|  | ||||
|     return merged_dict | ||||
|  | ||||
|  | ||||
| class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]): | ||||
|     """Custom type adapter for Pendulum DateTime fields.""" | ||||
|  | ||||
|   | ||||
| @@ -1,113 +1,2 @@ | ||||
| { | ||||
|   "config_file_path": null, | ||||
|   "config_folder_path": null, | ||||
|   "data_cache_path": null, | ||||
|   "data_cache_subpath": null, | ||||
|   "data_folder_path": null, | ||||
|   "data_output_path": null, | ||||
|   "data_output_subpath": null, | ||||
|   "elecprice_charges_kwh": 0.21, | ||||
|   "elecprice_provider": null, | ||||
|   "elecpriceimport_file_path": null, | ||||
|   "latitude": 52.5, | ||||
|   "load_import_file_path": null, | ||||
|   "load_name": null, | ||||
|   "load_provider": null, | ||||
|   "loadakkudoktor_year_energy": null, | ||||
|   "logging_level": "INFO", | ||||
|   "longitude": 13.4, | ||||
|   "optimization_ev_available_charge_rates_percent": null, | ||||
|   "optimization_hours": 48, | ||||
|   "optimization_penalty": null, | ||||
|   "prediction_historic_hours": 48, | ||||
|   "prediction_hours": 48, | ||||
|   "pvforecast0_albedo": null, | ||||
|   "pvforecast0_inverter_model": null, | ||||
|   "pvforecast0_inverter_paco": null, | ||||
|   "pvforecast0_loss": null, | ||||
|   "pvforecast0_module_model": null, | ||||
|   "pvforecast0_modules_per_string": null, | ||||
|   "pvforecast0_mountingplace": "free", | ||||
|   "pvforecast0_optimal_surface_tilt": false, | ||||
|   "pvforecast0_optimalangles": false, | ||||
|   "pvforecast0_peakpower": null, | ||||
|   "pvforecast0_pvtechchoice": "crystSi", | ||||
|   "pvforecast0_strings_per_inverter": null, | ||||
|   "pvforecast0_surface_azimuth": 180, | ||||
|   "pvforecast0_surface_tilt": 0, | ||||
|   "pvforecast0_trackingtype": 0, | ||||
|   "pvforecast0_userhorizon": null, | ||||
|   "pvforecast1_albedo": null, | ||||
|   "pvforecast1_inverter_model": null, | ||||
|   "pvforecast1_inverter_paco": null, | ||||
|   "pvforecast1_loss": 0, | ||||
|   "pvforecast1_module_model": null, | ||||
|   "pvforecast1_modules_per_string": null, | ||||
|   "pvforecast1_mountingplace": "free", | ||||
|   "pvforecast1_optimal_surface_tilt": false, | ||||
|   "pvforecast1_optimalangles": false, | ||||
|   "pvforecast1_peakpower": null, | ||||
|   "pvforecast1_pvtechchoice": "crystSi", | ||||
|   "pvforecast1_strings_per_inverter": null, | ||||
|   "pvforecast1_surface_azimuth": 180, | ||||
|   "pvforecast1_surface_tilt": 0, | ||||
|   "pvforecast1_trackingtype": 0, | ||||
|   "pvforecast1_userhorizon": null, | ||||
|   "pvforecast2_albedo": null, | ||||
|   "pvforecast2_inverter_model": null, | ||||
|   "pvforecast2_inverter_paco": null, | ||||
|   "pvforecast2_loss": 0, | ||||
|   "pvforecast2_module_model": null, | ||||
|   "pvforecast2_modules_per_string": null, | ||||
|   "pvforecast2_mountingplace": "free", | ||||
|   "pvforecast2_optimal_surface_tilt": false, | ||||
|   "pvforecast2_optimalangles": false, | ||||
|   "pvforecast2_peakpower": null, | ||||
|   "pvforecast2_pvtechchoice": "crystSi", | ||||
|   "pvforecast2_strings_per_inverter": null, | ||||
|   "pvforecast2_surface_azimuth": 180, | ||||
|   "pvforecast2_surface_tilt": 0, | ||||
|   "pvforecast2_trackingtype": 0, | ||||
|   "pvforecast2_userhorizon": null, | ||||
|   "pvforecast3_albedo": null, | ||||
|   "pvforecast3_inverter_model": null, | ||||
|   "pvforecast3_inverter_paco": null, | ||||
|   "pvforecast3_loss": 0, | ||||
|   "pvforecast3_module_model": null, | ||||
|   "pvforecast3_modules_per_string": null, | ||||
|   "pvforecast3_mountingplace": "free", | ||||
|   "pvforecast3_optimal_surface_tilt": false, | ||||
|   "pvforecast3_optimalangles": false, | ||||
|   "pvforecast3_peakpower": null, | ||||
|   "pvforecast3_pvtechchoice": "crystSi", | ||||
|   "pvforecast3_strings_per_inverter": null, | ||||
|   "pvforecast3_surface_azimuth": 180, | ||||
|   "pvforecast3_surface_tilt": 0, | ||||
|   "pvforecast3_trackingtype": 0, | ||||
|   "pvforecast3_userhorizon": null, | ||||
|   "pvforecast4_albedo": null, | ||||
|   "pvforecast4_inverter_model": null, | ||||
|   "pvforecast4_inverter_paco": null, | ||||
|   "pvforecast4_loss": 0, | ||||
|   "pvforecast4_module_model": null, | ||||
|   "pvforecast4_modules_per_string": null, | ||||
|   "pvforecast4_mountingplace": "free", | ||||
|   "pvforecast4_optimal_surface_tilt": false, | ||||
|   "pvforecast4_optimalangles": false, | ||||
|   "pvforecast4_peakpower": null, | ||||
|   "pvforecast4_pvtechchoice": "crystSi", | ||||
|   "pvforecast4_strings_per_inverter": null, | ||||
|   "pvforecast4_surface_azimuth": 180, | ||||
|   "pvforecast4_surface_tilt": 0, | ||||
|   "pvforecast4_trackingtype": 0, | ||||
|   "pvforecast4_userhorizon": null, | ||||
|   "pvforecast_provider": null, | ||||
|   "pvforecastimport_file_path": null, | ||||
|   "server_eos_startup_eosdash": true, | ||||
|   "server_eos_host": "0.0.0.0", | ||||
|   "server_eos_port": 8503, | ||||
|   "server_eosdash_host": "0.0.0.0", | ||||
|   "server_eosdash_port": 8504, | ||||
|   "weather_provider": null, | ||||
|   "weatherimport_file_path": null | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,14 @@ | ||||
| from typing import Any, Optional | ||||
|  | ||||
| import numpy as np | ||||
| from pydantic import BaseModel, Field, field_validator | ||||
| from pydantic import Field, field_validator | ||||
|  | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.core.pydantic import ParametersBaseModel | ||||
| from akkudoktoreos.devices.devicesabc import DeviceBase | ||||
| from akkudoktoreos.devices.devicesabc import ( | ||||
|     DeviceBase, | ||||
|     DeviceOptimizeResult, | ||||
|     DeviceParameters, | ||||
| ) | ||||
| from akkudoktoreos.utils.utils import NumpyEncoder | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| @@ -25,9 +28,10 @@ def initial_soc_percentage_field(description: str) -> int: | ||||
|     return Field(default=0, ge=0, le=100, description=description) | ||||
|  | ||||
|  | ||||
| class BaseBatteryParameters(ParametersBaseModel): | ||||
| class BaseBatteryParameters(DeviceParameters): | ||||
|     """Base class for battery parameters with fields for capacity, efficiency, and state of charge.""" | ||||
|  | ||||
|     device_id: str = Field(description="ID of battery") | ||||
|     capacity_wh: int = Field( | ||||
|         gt=0, description="An integer representing the capacity of the battery in watt-hours." | ||||
|     ) | ||||
| @@ -68,15 +72,17 @@ class SolarPanelBatteryParameters(BaseBatteryParameters): | ||||
| class ElectricVehicleParameters(BaseBatteryParameters): | ||||
|     """Parameters specific to an electric vehicle (EV).""" | ||||
|  | ||||
|     device_id: str = Field(description="ID of electric vehicle") | ||||
|     discharging_efficiency: float = 1.0 | ||||
|     initial_soc_percentage: int = initial_soc_percentage_field( | ||||
|         "An integer representing the current state of charge (SOC) of the battery in percentage." | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class ElectricVehicleResult(BaseModel): | ||||
| class ElectricVehicleResult(DeviceOptimizeResult): | ||||
|     """Result class containing information related to the electric vehicle's charging and discharging behavior.""" | ||||
|  | ||||
|     device_id: str = Field(description="ID of electric vehicle") | ||||
|     charge_array: list[float] = Field( | ||||
|         description="Hourly charging status (0 for no charging, 1 for charging)." | ||||
|     ) | ||||
| @@ -84,7 +90,6 @@ class ElectricVehicleResult(BaseModel): | ||||
|         description="Hourly discharging status (0 for no discharging, 1 for discharging)." | ||||
|     ) | ||||
|     discharging_efficiency: float = Field(description="The discharge efficiency as a float..") | ||||
|     hours: int = Field(description="Number of hours in the simulation.") | ||||
|     capacity_wh: int = Field(description="Capacity of the EV’s battery in watt-hours.") | ||||
|     charging_efficiency: float = Field(description="Charging efficiency as a float..") | ||||
|     max_charge_power_w: int = Field(description="Maximum charging power in watts.") | ||||
| @@ -103,81 +108,30 @@ class ElectricVehicleResult(BaseModel): | ||||
| class Battery(DeviceBase): | ||||
|     """Represents a battery device with methods to simulate energy charging and discharging.""" | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         parameters: Optional[BaseBatteryParameters] = None, | ||||
|         hours: Optional[int] = 24, | ||||
|         provider_id: Optional[str] = None, | ||||
|     ): | ||||
|         # Initialize configuration and parameters | ||||
|         self.provider_id = provider_id | ||||
|         self.prefix = "<invalid>" | ||||
|         if self.provider_id == "GenericBattery": | ||||
|             self.prefix = "battery" | ||||
|         elif self.provider_id == "GenericBEV": | ||||
|             self.prefix = "bev" | ||||
|     def __init__(self, parameters: Optional[BaseBatteryParameters] = None): | ||||
|         self.parameters: Optional[BaseBatteryParameters] = None | ||||
|         super().__init__(parameters) | ||||
|  | ||||
|         self.parameters = parameters | ||||
|         if hours is None: | ||||
|             self.hours = self.total_hours  # TODO where does that come from? | ||||
|         else: | ||||
|             self.hours = hours | ||||
|  | ||||
|         self.initialised = False | ||||
|  | ||||
|         # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. | ||||
|         if self.parameters is not None: | ||||
|             self.setup() | ||||
|  | ||||
|     def setup(self) -> None: | ||||
|     def _setup(self) -> None: | ||||
|         """Sets up the battery parameters based on configuration or provided parameters.""" | ||||
|         if self.initialised: | ||||
|             return | ||||
|         assert self.parameters is not None | ||||
|         self.capacity_wh = self.parameters.capacity_wh | ||||
|         self.initial_soc_percentage = self.parameters.initial_soc_percentage | ||||
|         self.charging_efficiency = self.parameters.charging_efficiency | ||||
|         self.discharging_efficiency = self.parameters.discharging_efficiency | ||||
|  | ||||
|         if self.provider_id: | ||||
|             # Setup from configuration | ||||
|             self.capacity_wh = getattr(self.config, f"{self.prefix}_capacity") | ||||
|             self.initial_soc_percentage = getattr(self.config, f"{self.prefix}_initial_soc") | ||||
|             self.hours = self.total_hours  # TODO where does that come from? | ||||
|             self.charging_efficiency = getattr(self.config, f"{self.prefix}_charging_efficiency") | ||||
|             self.discharging_efficiency = getattr( | ||||
|                 self.config, f"{self.prefix}_discharging_efficiency" | ||||
|             ) | ||||
|             self.max_charge_power_w = getattr(self.config, f"{self.prefix}_max_charging_power") | ||||
|  | ||||
|             if self.provider_id == "GenericBattery": | ||||
|                 self.min_soc_percentage = getattr( | ||||
|                     self.config, | ||||
|                     f"{self.prefix}_soc_min", | ||||
|                 ) | ||||
|             else: | ||||
|                 self.min_soc_percentage = 0 | ||||
|  | ||||
|             self.max_soc_percentage = getattr( | ||||
|                 self.config, | ||||
|                 f"{self.prefix}_soc_max", | ||||
|             ) | ||||
|         elif self.parameters: | ||||
|             # Setup from parameters | ||||
|             self.capacity_wh = self.parameters.capacity_wh | ||||
|             self.initial_soc_percentage = self.parameters.initial_soc_percentage | ||||
|             self.charging_efficiency = self.parameters.charging_efficiency | ||||
|             self.discharging_efficiency = self.parameters.discharging_efficiency | ||||
|             self.max_charge_power_w = self.parameters.max_charge_power_w | ||||
|             # Only assign for storage battery | ||||
|             self.min_soc_percentage = ( | ||||
|                 self.parameters.min_soc_percentage | ||||
|                 if isinstance(self.parameters, SolarPanelBatteryParameters) | ||||
|                 else 0 | ||||
|             ) | ||||
|             self.max_soc_percentage = self.parameters.max_soc_percentage | ||||
|         else: | ||||
|             error_msg = "Parameters and provider ID are missing. Cannot instantiate." | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|         # Only assign for storage battery | ||||
|         self.min_soc_percentage = ( | ||||
|             self.parameters.min_soc_percentage | ||||
|             if isinstance(self.parameters, SolarPanelBatteryParameters) | ||||
|             else 0 | ||||
|         ) | ||||
|         self.max_soc_percentage = self.parameters.max_soc_percentage | ||||
|  | ||||
|         # Initialize state of charge | ||||
|         if self.max_charge_power_w is None: | ||||
|         if self.parameters.max_charge_power_w is not None: | ||||
|             self.max_charge_power_w = self.parameters.max_charge_power_w | ||||
|         else: | ||||
|             self.max_charge_power_w = self.capacity_wh  # TODO this should not be equal capacity_wh | ||||
|         self.discharge_array = np.full(self.hours, 1) | ||||
|         self.charge_array = np.full(self.hours, 1) | ||||
| @@ -185,11 +139,10 @@ class Battery(DeviceBase): | ||||
|         self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh | ||||
|         self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh | ||||
|  | ||||
|         self.initialised = True | ||||
|  | ||||
|     def to_dict(self) -> dict[str, Any]: | ||||
|         """Converts the object to a dictionary representation.""" | ||||
|         return { | ||||
|             "device_id": self.device_id, | ||||
|             "capacity_wh": self.capacity_wh, | ||||
|             "initial_soc_percentage": self.initial_soc_percentage, | ||||
|             "soc_wh": self.soc_wh, | ||||
|   | ||||
| @@ -1,307 +1,189 @@ | ||||
| from typing import Any, ClassVar, Dict, Optional, Union | ||||
| from typing import Optional | ||||
|  | ||||
| import numpy as np | ||||
| from numpydantic import NDArray, Shape | ||||
| from pydantic import Field, computed_field | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.coreabc import SingletonMixin | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.devices.battery import Battery | ||||
| from akkudoktoreos.devices.devicesabc import DevicesBase | ||||
| from akkudoktoreos.devices.generic import HomeAppliance | ||||
| from akkudoktoreos.devices.inverter import Inverter | ||||
| from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator | ||||
| from akkudoktoreos.utils.datetimeutil import to_duration | ||||
| from akkudoktoreos.devices.settings import DevicesCommonSettings | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| class DevicesCommonSettings(SettingsBaseModel): | ||||
|     """Base configuration for devices simulation settings.""" | ||||
|  | ||||
|     # Battery | ||||
|     # ------- | ||||
|     battery_provider: Optional[str] = Field( | ||||
|         default=None, description="Id of Battery simulation provider." | ||||
|     ) | ||||
|     battery_capacity: Optional[int] = Field(default=None, description="Battery capacity [Wh].") | ||||
|     battery_initial_soc: Optional[int] = Field( | ||||
|         default=None, description="Battery initial state of charge [%]." | ||||
|     ) | ||||
|     battery_soc_min: Optional[int] = Field( | ||||
|         default=None, description="Battery minimum state of charge [%]." | ||||
|     ) | ||||
|     battery_soc_max: Optional[int] = Field( | ||||
|         default=None, description="Battery maximum state of charge [%]." | ||||
|     ) | ||||
|     battery_charging_efficiency: Optional[float] = Field( | ||||
|         default=None, description="Battery charging efficiency [%]." | ||||
|     ) | ||||
|     battery_discharging_efficiency: Optional[float] = Field( | ||||
|         default=None, description="Battery discharging efficiency [%]." | ||||
|     ) | ||||
|     battery_max_charging_power: Optional[int] = Field( | ||||
|         default=None, description="Battery maximum charge power [W]." | ||||
|     ) | ||||
|  | ||||
|     # Battery Electric Vehicle | ||||
|     # ------------------------ | ||||
|     bev_provider: Optional[str] = Field( | ||||
|         default=None, description="Id of Battery Electric Vehicle simulation provider." | ||||
|     ) | ||||
|     bev_capacity: Optional[int] = Field( | ||||
|         default=None, description="Battery Electric Vehicle capacity [Wh]." | ||||
|     ) | ||||
|     bev_initial_soc: Optional[int] = Field( | ||||
|         default=None, description="Battery Electric Vehicle initial state of charge [%]." | ||||
|     ) | ||||
|     bev_soc_max: Optional[int] = Field( | ||||
|         default=None, description="Battery Electric Vehicle maximum state of charge [%]." | ||||
|     ) | ||||
|     bev_charging_efficiency: Optional[float] = Field( | ||||
|         default=None, description="Battery Electric Vehicle charging efficiency [%]." | ||||
|     ) | ||||
|     bev_discharging_efficiency: Optional[float] = Field( | ||||
|         default=None, description="Battery Electric Vehicle discharging efficiency [%]." | ||||
|     ) | ||||
|     bev_max_charging_power: Optional[int] = Field( | ||||
|         default=None, description="Battery Electric Vehicle maximum charge power [W]." | ||||
|     ) | ||||
|  | ||||
|     # Home Appliance - Dish Washer | ||||
|     # ---------------------------- | ||||
|     dishwasher_provider: Optional[str] = Field( | ||||
|         default=None, description="Id of Dish Washer simulation provider." | ||||
|     ) | ||||
|     dishwasher_consumption: Optional[int] = Field( | ||||
|         default=None, description="Dish Washer energy consumption [Wh]." | ||||
|     ) | ||||
|     dishwasher_duration: Optional[int] = Field( | ||||
|         default=None, description="Dish Washer usage duration [h]." | ||||
|     ) | ||||
|  | ||||
|     # PV Inverter | ||||
|     # ----------- | ||||
|     inverter_provider: Optional[str] = Field( | ||||
|         default=None, description="Id of PV Inverter simulation provider." | ||||
|     ) | ||||
|     inverter_power_max: Optional[float] = Field( | ||||
|         default=None, description="Inverter maximum power [W]." | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class Devices(SingletonMixin, DevicesBase): | ||||
|     # Results of the devices simulation and | ||||
|     # insights into various parameters over the entire forecast period. | ||||
|     # ----------------------------------------------------------------- | ||||
|     last_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, description="The load in watt-hours per hour." | ||||
|     ) | ||||
|     eauto_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, description="The state of charge of the EV for each hour." | ||||
|     ) | ||||
|     einnahmen_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, | ||||
|         description="The revenue from grid feed-in or other sources in euros per hour.", | ||||
|     ) | ||||
|     home_appliance_wh_per_hour: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, | ||||
|         description="The energy consumption of a household appliance in watt-hours per hour.", | ||||
|     ) | ||||
|     kosten_euro_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, description="The costs in euros per hour." | ||||
|     ) | ||||
|     grid_import_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, description="The grid energy drawn in watt-hours per hour." | ||||
|     ) | ||||
|     grid_export_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, description="The energy fed into the grid in watt-hours per hour." | ||||
|     ) | ||||
|     verluste_wh_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, description="The losses in watt-hours per hour." | ||||
|     ) | ||||
|     akku_soc_pro_stunde: Optional[NDArray[Shape["*"], float]] = Field( | ||||
|         default=None, | ||||
|         description="The state of charge of the battery (not the EV) in percentage per hour.", | ||||
|     ) | ||||
|     def __init__(self, settings: Optional[DevicesCommonSettings] = None): | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         super().__init__() | ||||
|         if settings is None: | ||||
|             settings = self.config.devices | ||||
|             if settings is None: | ||||
|                 return | ||||
|  | ||||
|     # Computed fields | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def total_balance_euro(self) -> float: | ||||
|         """The total balance of revenues minus costs in euros.""" | ||||
|         return self.total_revenues_euro - self.total_costs_euro | ||||
|         # initialize devices | ||||
|         if settings.batteries is not None: | ||||
|             for battery_params in settings.batteries: | ||||
|                 self.add_device(Battery(battery_params)) | ||||
|         if settings.inverters is not None: | ||||
|             for inverter_params in settings.inverters: | ||||
|                 self.add_device(Inverter(inverter_params)) | ||||
|         if settings.home_appliances is not None: | ||||
|             for home_appliance_params in settings.home_appliances: | ||||
|                 self.add_device(HomeAppliance(home_appliance_params)) | ||||
|  | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def total_revenues_euro(self) -> float: | ||||
|         """The total revenues in euros.""" | ||||
|         if self.einnahmen_euro_pro_stunde is None: | ||||
|             return 0 | ||||
|         return np.nansum(self.einnahmen_euro_pro_stunde) | ||||
|         self.post_setup() | ||||
|  | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def total_costs_euro(self) -> float: | ||||
|         """The total costs in euros.""" | ||||
|         if self.kosten_euro_pro_stunde is None: | ||||
|             return 0 | ||||
|         return np.nansum(self.kosten_euro_pro_stunde) | ||||
|     def post_setup(self) -> None: | ||||
|         for device in self.devices.values(): | ||||
|             device.post_setup() | ||||
|  | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|     def total_losses_wh(self) -> float: | ||||
|         """The total losses in watt-hours over the entire period.""" | ||||
|         if self.verluste_wh_pro_stunde is None: | ||||
|             return 0 | ||||
|         return np.nansum(self.verluste_wh_pro_stunde) | ||||
|  | ||||
|     # Devices | ||||
|     # TODO: Make devices class a container of device simulation providers. | ||||
|     #       Device simulations to be used are then enabled in the configuration. | ||||
|     battery: ClassVar[Battery] = Battery(provider_id="GenericBattery") | ||||
|     ev: ClassVar[Battery] = Battery(provider_id="GenericBEV") | ||||
|     home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher") | ||||
|     inverter: ClassVar[Inverter] = Inverter( | ||||
|         self_consumption_predictor=SelfConsumptionProbabilityInterpolator, | ||||
|         battery=battery, | ||||
|         provider_id="GenericInverter", | ||||
|     ) | ||||
|  | ||||
|     def update_data(self) -> None: | ||||
|         """Update device simulation data.""" | ||||
|         # Assure devices are set up | ||||
|         self.battery.setup() | ||||
|         self.ev.setup() | ||||
|         self.home_appliance.setup() | ||||
|         self.inverter.setup() | ||||
|  | ||||
|         # Pre-allocate arrays for the results, optimized for speed | ||||
|         self.last_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
|         self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan) | ||||
|  | ||||
|         # Set initial state | ||||
|         simulation_step = to_duration("1 hour") | ||||
|         if self.battery: | ||||
|             self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage() | ||||
|         if self.ev: | ||||
|             self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage() | ||||
|  | ||||
|         # Get predictions for full device simulation time range | ||||
|         # gesamtlast[stunde] | ||||
|         load_total_mean = self.prediction.key_to_array( | ||||
|             "load_total_mean", | ||||
|             start_datetime=self.start_datetime, | ||||
|             end_datetime=self.end_datetime, | ||||
|             interval=simulation_step, | ||||
|         ) | ||||
|         # pv_prognose_wh[stunde] | ||||
|         pvforecast_ac_power = self.prediction.key_to_array( | ||||
|             "pvforecast_ac_power", | ||||
|             start_datetime=self.start_datetime, | ||||
|             end_datetime=self.end_datetime, | ||||
|             interval=simulation_step, | ||||
|         ) | ||||
|         # strompreis_euro_pro_wh[stunde] | ||||
|         elecprice_marketprice_wh = self.prediction.key_to_array( | ||||
|             "elecprice_marketprice_wh", | ||||
|             start_datetime=self.start_datetime, | ||||
|             end_datetime=self.end_datetime, | ||||
|             interval=simulation_step, | ||||
|         ) | ||||
|         # einspeiseverguetung_euro_pro_wh_arr[stunde] | ||||
|         # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr | ||||
|         einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078) | ||||
|  | ||||
|         for stunde_since_now in range(0, self.total_hours): | ||||
|             hour = self.start_datetime.hour + stunde_since_now | ||||
|  | ||||
|             # Accumulate loads and PV generation | ||||
|             consumption = load_total_mean[stunde_since_now] | ||||
|             self.verluste_wh_pro_stunde[stunde_since_now] = 0.0 | ||||
|  | ||||
|             # Home appliances | ||||
|             if self.home_appliance: | ||||
|                 ha_load = self.home_appliance.get_load_for_hour(hour) | ||||
|                 consumption += ha_load | ||||
|                 self.home_appliance_wh_per_hour[stunde_since_now] = ha_load | ||||
|  | ||||
|             # E-Auto handling | ||||
|             if self.ev: | ||||
|                 if self.ev_charge_hours[hour] > 0: | ||||
|                     geladene_menge_eauto, verluste_eauto = self.ev.charge_energy( | ||||
|                         None, hour, relative_power=self.ev_charge_hours[hour] | ||||
|                     ) | ||||
|                     consumption += geladene_menge_eauto | ||||
|                     self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto | ||||
|                 self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage() | ||||
|  | ||||
|             # Process inverter logic | ||||
|             grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0) | ||||
|             if self.battery: | ||||
|                 self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour) | ||||
|             if self.inverter: | ||||
|                 generation = pvforecast_ac_power[hour] | ||||
|                 grid_export, grid_import, losses, self_consumption = self.inverter.process_energy( | ||||
|                     generation, consumption, hour | ||||
|                 ) | ||||
|  | ||||
|             # AC PV Battery Charge | ||||
|             if self.battery and self.ac_charge_hours[hour] > 0.0: | ||||
|                 self.battery.set_charge_allowed_for_hour(1, hour) | ||||
|                 geladene_menge, verluste_wh = self.battery.charge_energy( | ||||
|                     None, hour, relative_power=self.ac_charge_hours[hour] | ||||
|                 ) | ||||
|                 # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage()) | ||||
|                 consumption += geladene_menge | ||||
|                 grid_import += geladene_menge | ||||
|                 self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh | ||||
|  | ||||
|             self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export | ||||
|             self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import | ||||
|             self.verluste_wh_pro_stunde[stunde_since_now] += losses | ||||
|             self.last_wh_pro_stunde[stunde_since_now] = consumption | ||||
|  | ||||
|             # Financial calculations | ||||
|             self.kosten_euro_pro_stunde[stunde_since_now] = ( | ||||
|                 grid_import * self.strompreis_euro_pro_wh[hour] | ||||
|             ) | ||||
|             self.einnahmen_euro_pro_stunde[stunde_since_now] = ( | ||||
|                 grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour] | ||||
|             ) | ||||
|  | ||||
|             # battery SOC tracking | ||||
|             if self.battery: | ||||
|                 self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage() | ||||
|             else: | ||||
|                 self.akku_soc_pro_stunde[stunde_since_now] = 0.0 | ||||
|  | ||||
|     def report_dict(self) -> Dict[str, Any]: | ||||
|         """Provides devices simulation output as a dictionary.""" | ||||
|         out: Dict[str, Optional[Union[np.ndarray, float]]] = { | ||||
|             "Last_Wh_pro_Stunde": self.last_wh_pro_stunde, | ||||
|             "grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde, | ||||
|             "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde, | ||||
|             "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde, | ||||
|             "akku_soc_pro_stunde": self.akku_soc_pro_stunde, | ||||
|             "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde, | ||||
|             "Gesamtbilanz_Euro": self.total_balance_euro, | ||||
|             "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde, | ||||
|             "Gesamteinnahmen_Euro": self.total_revenues_euro, | ||||
|             "Gesamtkosten_Euro": self.total_costs_euro, | ||||
|             "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde, | ||||
|             "Gesamt_Verluste": self.total_losses_wh, | ||||
|             "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour, | ||||
|         } | ||||
|         return out | ||||
| #    # Devices | ||||
| #    # TODO: Make devices class a container of device simulation providers. | ||||
| #    #       Device simulations to be used are then enabled in the configuration. | ||||
| #    battery: ClassVar[Battery] = Battery(provider_id="GenericBattery") | ||||
| #    ev: ClassVar[Battery] = Battery(provider_id="GenericBEV") | ||||
| #    home_appliance: ClassVar[HomeAppliance] = HomeAppliance(provider_id="GenericDishWasher") | ||||
| #    inverter: ClassVar[Inverter] = Inverter( | ||||
| #        self_consumption_predictor=SelfConsumptionProbabilityInterpolator, | ||||
| #        battery=battery, | ||||
| #        provider_id="GenericInverter", | ||||
| #    ) | ||||
| # | ||||
| #    def update_data(self) -> None: | ||||
| #        """Update device simulation data.""" | ||||
| #        # Assure devices are set up | ||||
| #        self.battery.setup() | ||||
| #        self.ev.setup() | ||||
| #        self.home_appliance.setup() | ||||
| #        self.inverter.setup() | ||||
| # | ||||
| #        # Pre-allocate arrays for the results, optimized for speed | ||||
| #        self.last_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.grid_export_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.grid_import_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.kosten_euro_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.einnahmen_euro_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.akku_soc_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.eauto_soc_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.verluste_wh_pro_stunde = np.full((self.total_hours), np.nan) | ||||
| #        self.home_appliance_wh_per_hour = np.full((self.total_hours), np.nan) | ||||
| # | ||||
| #        # Set initial state | ||||
| #        simulation_step = to_duration("1 hour") | ||||
| #        if self.battery: | ||||
| #            self.akku_soc_pro_stunde[0] = self.battery.current_soc_percentage() | ||||
| #        if self.ev: | ||||
| #            self.eauto_soc_pro_stunde[0] = self.ev.current_soc_percentage() | ||||
| # | ||||
| #        # Get predictions for full device simulation time range | ||||
| #        # gesamtlast[stunde] | ||||
| #        load_total_mean = self.prediction.key_to_array( | ||||
| #            "load_total_mean", | ||||
| #            start_datetime=self.start_datetime, | ||||
| #            end_datetime=self.end_datetime, | ||||
| #            interval=simulation_step, | ||||
| #        ) | ||||
| #        # pv_prognose_wh[stunde] | ||||
| #        pvforecast_ac_power = self.prediction.key_to_array( | ||||
| #            "pvforecast_ac_power", | ||||
| #            start_datetime=self.start_datetime, | ||||
| #            end_datetime=self.end_datetime, | ||||
| #            interval=simulation_step, | ||||
| #        ) | ||||
| #        # strompreis_euro_pro_wh[stunde] | ||||
| #        elecprice_marketprice_wh = self.prediction.key_to_array( | ||||
| #            "elecprice_marketprice_wh", | ||||
| #            start_datetime=self.start_datetime, | ||||
| #            end_datetime=self.end_datetime, | ||||
| #            interval=simulation_step, | ||||
| #        ) | ||||
| #        # einspeiseverguetung_euro_pro_wh_arr[stunde] | ||||
| #        # TODO: Create prediction for einspeiseverguetung_euro_pro_wh_arr | ||||
| #        einspeiseverguetung_euro_pro_wh_arr = np.full((self.total_hours), 0.078) | ||||
| # | ||||
| #        for stunde_since_now in range(0, self.total_hours): | ||||
| #            hour = self.start_datetime.hour + stunde_since_now | ||||
| # | ||||
| #            # Accumulate loads and PV generation | ||||
| #            consumption = load_total_mean[stunde_since_now] | ||||
| #            self.verluste_wh_pro_stunde[stunde_since_now] = 0.0 | ||||
| # | ||||
| #            # Home appliances | ||||
| #            if self.home_appliance: | ||||
| #                ha_load = self.home_appliance.get_load_for_hour(hour) | ||||
| #                consumption += ha_load | ||||
| #                self.home_appliance_wh_per_hour[stunde_since_now] = ha_load | ||||
| # | ||||
| #            # E-Auto handling | ||||
| #            if self.ev: | ||||
| #                if self.ev_charge_hours[hour] > 0: | ||||
| #                    geladene_menge_eauto, verluste_eauto = self.ev.charge_energy( | ||||
| #                        None, hour, relative_power=self.ev_charge_hours[hour] | ||||
| #                    ) | ||||
| #                    consumption += geladene_menge_eauto | ||||
| #                    self.verluste_wh_pro_stunde[stunde_since_now] += verluste_eauto | ||||
| #                self.eauto_soc_pro_stunde[stunde_since_now] = self.ev.current_soc_percentage() | ||||
| # | ||||
| #            # Process inverter logic | ||||
| #            grid_export, grid_import, losses, self_consumption = (0.0, 0.0, 0.0, 0.0) | ||||
| #            if self.battery: | ||||
| #                self.battery.set_charge_allowed_for_hour(self.dc_charge_hours[hour], hour) | ||||
| #            if self.inverter: | ||||
| #                generation = pvforecast_ac_power[hour] | ||||
| #                grid_export, grid_import, losses, self_consumption = self.inverter.process_energy( | ||||
| #                    generation, consumption, hour | ||||
| #                ) | ||||
| # | ||||
| #            # AC PV Battery Charge | ||||
| #            if self.battery and self.ac_charge_hours[hour] > 0.0: | ||||
| #                self.battery.set_charge_allowed_for_hour(1, hour) | ||||
| #                geladene_menge, verluste_wh = self.battery.charge_energy( | ||||
| #                    None, hour, relative_power=self.ac_charge_hours[hour] | ||||
| #                ) | ||||
| #                # print(stunde, " ", geladene_menge, " ",self.ac_charge_hours[stunde]," ",self.battery.current_soc_percentage()) | ||||
| #                consumption += geladene_menge | ||||
| #                grid_import += geladene_menge | ||||
| #                self.verluste_wh_pro_stunde[stunde_since_now] += verluste_wh | ||||
| # | ||||
| #            self.grid_export_wh_pro_stunde[stunde_since_now] = grid_export | ||||
| #            self.grid_import_wh_pro_stunde[stunde_since_now] = grid_import | ||||
| #            self.verluste_wh_pro_stunde[stunde_since_now] += losses | ||||
| #            self.last_wh_pro_stunde[stunde_since_now] = consumption | ||||
| # | ||||
| #            # Financial calculations | ||||
| #            self.kosten_euro_pro_stunde[stunde_since_now] = ( | ||||
| #                grid_import * self.strompreis_euro_pro_wh[hour] | ||||
| #            ) | ||||
| #            self.einnahmen_euro_pro_stunde[stunde_since_now] = ( | ||||
| #                grid_export * self.einspeiseverguetung_euro_pro_wh_arr[hour] | ||||
| #            ) | ||||
| # | ||||
| #            # battery SOC tracking | ||||
| #            if self.battery: | ||||
| #                self.akku_soc_pro_stunde[stunde_since_now] = self.battery.current_soc_percentage() | ||||
| #            else: | ||||
| #                self.akku_soc_pro_stunde[stunde_since_now] = 0.0 | ||||
| # | ||||
| #    def report_dict(self) -> Dict[str, Any]: | ||||
| #        """Provides devices simulation output as a dictionary.""" | ||||
| #        out: Dict[str, Optional[Union[np.ndarray, float]]] = { | ||||
| #            "Last_Wh_pro_Stunde": self.last_wh_pro_stunde, | ||||
| #            "grid_export_Wh_pro_Stunde": self.grid_export_wh_pro_stunde, | ||||
| #            "grid_import_Wh_pro_Stunde": self.grid_import_wh_pro_stunde, | ||||
| #            "Kosten_Euro_pro_Stunde": self.kosten_euro_pro_stunde, | ||||
| #            "akku_soc_pro_stunde": self.akku_soc_pro_stunde, | ||||
| #            "Einnahmen_Euro_pro_Stunde": self.einnahmen_euro_pro_stunde, | ||||
| #            "Gesamtbilanz_Euro": self.total_balance_euro, | ||||
| #            "EAuto_SoC_pro_Stunde": self.eauto_soc_pro_stunde, | ||||
| #            "Gesamteinnahmen_Euro": self.total_revenues_euro, | ||||
| #            "Gesamtkosten_Euro": self.total_costs_euro, | ||||
| #            "Verluste_Pro_Stunde": self.verluste_wh_pro_stunde, | ||||
| #            "Gesamt_Verluste": self.total_losses_wh, | ||||
| #            "Home_appliance_wh_per_hour": self.home_appliance_wh_per_hour, | ||||
| #        } | ||||
| #        return out | ||||
|  | ||||
|  | ||||
| # Initialize the Devices  simulation, it is a singleton. | ||||
|   | ||||
| @@ -1,22 +1,46 @@ | ||||
| """Abstract and base classes for devices.""" | ||||
|  | ||||
| from typing import Optional | ||||
| from enum import Enum | ||||
| from typing import Optional, Type | ||||
|  | ||||
| from pendulum import DateTime | ||||
| from pydantic import ConfigDict, computed_field | ||||
| from pydantic import Field, computed_field | ||||
|  | ||||
| from akkudoktoreos.core.coreabc import ( | ||||
|     ConfigMixin, | ||||
|     DevicesMixin, | ||||
|     EnergyManagementSystemMixin, | ||||
|     PredictionMixin, | ||||
| ) | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.core.pydantic import PydanticBaseModel | ||||
| from akkudoktoreos.core.pydantic import ParametersBaseModel | ||||
| from akkudoktoreos.utils.datetimeutil import to_duration | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| # class DeviceParameters(PydanticBaseModel): | ||||
| class DeviceParameters(ParametersBaseModel): | ||||
|     device_id: str = Field(description="ID of device") | ||||
|     hours: Optional[int] = Field( | ||||
|         default=None, | ||||
|         gt=0, | ||||
|         description="Number of prediction hours. Defaults to global config prediction hours.", | ||||
|     ) | ||||
|  | ||||
|  | ||||
| # class DeviceOptimizeResult(PydanticBaseModel): | ||||
| class DeviceOptimizeResult(ParametersBaseModel): | ||||
|     device_id: str = Field(description="ID of device") | ||||
|     hours: int = Field(gt=0, description="Number of hours in the simulation.") | ||||
|  | ||||
|  | ||||
| class DeviceState(Enum): | ||||
|     UNINITIALIZED = 0 | ||||
|     PREPARED = 1 | ||||
|     INITIALIZED = 2 | ||||
|  | ||||
|  | ||||
| class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): | ||||
|     """A mixin to manage start, end datetimes for devices data. | ||||
|  | ||||
| @@ -35,9 +59,9 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): | ||||
|         Returns: | ||||
|             Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. | ||||
|         """ | ||||
|         if self.ems.start_datetime and self.config.prediction_hours: | ||||
|         if self.ems.start_datetime and self.config.prediction.prediction_hours: | ||||
|             end_datetime = self.ems.start_datetime + to_duration( | ||||
|                 f"{self.config.prediction_hours} hours" | ||||
|                 f"{self.config.prediction.prediction_hours} hours" | ||||
|             ) | ||||
|             dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours | ||||
|             logger.debug( | ||||
| @@ -68,33 +92,92 @@ class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin): | ||||
|         return int(duration.total_hours()) | ||||
|  | ||||
|  | ||||
| class DeviceBase(DevicesStartEndMixin, PredictionMixin): | ||||
| class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin): | ||||
|     """Base class for device simulations. | ||||
|  | ||||
|     Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute | ||||
|     `prediction`). | ||||
|     Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute | ||||
|     `prediction`) and EOS device registry (attribute `devices`). | ||||
|  | ||||
|     Note: | ||||
|         Validation on assignment of the Pydantic model is disabled to speed up simulation runs. | ||||
|     Behavior: | ||||
|         - Several initialization phases (setup, post_setup): | ||||
|             - setup: Initialize class attributes from DeviceParameters (pydantic input validation) | ||||
|             - post_setup: Set connections between devices | ||||
|         - NotImplemented: | ||||
|             - hooks during optimization | ||||
|  | ||||
|     Notes: | ||||
|         - This class is base to concrete devices like battery, inverter, etc. that are used in optimization. | ||||
|         - Not a pydantic model for a low footprint during optimization. | ||||
|     """ | ||||
|  | ||||
|     # Disable validation on assignment to speed up simulation runs. | ||||
|     model_config = ConfigDict( | ||||
|         validate_assignment=False, | ||||
|     ) | ||||
|     def __init__(self, parameters: Optional[DeviceParameters] = None): | ||||
|         self.device_id: str = "<invalid>" | ||||
|         self.parameters: Optional[DeviceParameters] = None | ||||
|         self.hours = -1 | ||||
|         if self.total_hours is not None: | ||||
|             self.hours = self.total_hours | ||||
|  | ||||
|         self.initialized = DeviceState.UNINITIALIZED | ||||
|  | ||||
|         if parameters is not None: | ||||
|             self.setup(parameters) | ||||
|  | ||||
|     def setup(self, parameters: DeviceParameters) -> None: | ||||
|         if self.initialized != DeviceState.UNINITIALIZED: | ||||
|             return | ||||
|  | ||||
|         self.parameters = parameters | ||||
|         self.device_id = self.parameters.device_id | ||||
|  | ||||
|         if self.parameters.hours is not None: | ||||
|             self.hours = self.parameters.hours | ||||
|         if self.hours < 0: | ||||
|             raise ValueError("hours is unset") | ||||
|  | ||||
|         self._setup() | ||||
|  | ||||
|         self.initialized = DeviceState.PREPARED | ||||
|  | ||||
|     def post_setup(self) -> None: | ||||
|         if self.initialized.value >= DeviceState.INITIALIZED.value: | ||||
|             return | ||||
|  | ||||
|         self._post_setup() | ||||
|         self.initialized = DeviceState.INITIALIZED | ||||
|  | ||||
|     def _setup(self) -> None: | ||||
|         """Implement custom setup in derived device classes.""" | ||||
|         pass | ||||
|  | ||||
|     def _post_setup(self) -> None: | ||||
|         """Implement custom setup in derived device classes that is run when all devices are initialized.""" | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class DevicesBase(DevicesStartEndMixin, PredictionMixin, PydanticBaseModel): | ||||
| class DevicesBase(DevicesStartEndMixin, PredictionMixin): | ||||
|     """Base class for handling device data. | ||||
|  | ||||
|     Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute | ||||
|     `prediction`). | ||||
|  | ||||
|     Note: | ||||
|         Validation on assignment of the Pydantic model is disabled to speed up simulation runs. | ||||
|     """ | ||||
|  | ||||
|     # Disable validation on assignment to speed up simulation runs. | ||||
|     model_config = ConfigDict( | ||||
|         validate_assignment=False, | ||||
|     ) | ||||
|     def __init__(self) -> None: | ||||
|         super().__init__() | ||||
|         self.devices: dict[str, "DeviceBase"] = dict() | ||||
|  | ||||
|     def get_device_by_id(self, device_id: str) -> Optional["DeviceBase"]: | ||||
|         return self.devices.get(device_id) | ||||
|  | ||||
|     def add_device(self, device: Optional["DeviceBase"]) -> None: | ||||
|         if device is None: | ||||
|             return | ||||
|         assert device.device_id not in self.devices, f"{device.device_id} already registered" | ||||
|         self.devices[device.device_id] = device | ||||
|  | ||||
|     def remove_device(self, device: Type["DeviceBase"] | str) -> bool: | ||||
|         if isinstance(device, DeviceBase): | ||||
|             device = device.device_id | ||||
|         return self.devices.pop(device, None) is not None  # type: ignore[arg-type] | ||||
|  | ||||
|     def reset(self) -> None: | ||||
|         self.devices = dict() | ||||
|   | ||||
| @@ -4,13 +4,13 @@ import numpy as np | ||||
| from pydantic import Field | ||||
|  | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.core.pydantic import ParametersBaseModel | ||||
| from akkudoktoreos.devices.devicesabc import DeviceBase | ||||
| from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| class HomeApplianceParameters(ParametersBaseModel): | ||||
| class HomeApplianceParameters(DeviceParameters): | ||||
|     device_id: str = Field(description="ID of home appliance") | ||||
|     consumption_wh: int = Field( | ||||
|         gt=0, | ||||
|         description="An integer representing the energy consumption of a household device in watt-hours.", | ||||
| @@ -25,46 +25,15 @@ class HomeAppliance(DeviceBase): | ||||
|     def __init__( | ||||
|         self, | ||||
|         parameters: Optional[HomeApplianceParameters] = None, | ||||
|         hours: Optional[int] = 24, | ||||
|         provider_id: Optional[str] = None, | ||||
|     ): | ||||
|         # Configuration initialisation | ||||
|         self.provider_id = provider_id | ||||
|         self.prefix = "<invalid>" | ||||
|         if self.provider_id == "GenericDishWasher": | ||||
|             self.prefix = "dishwasher" | ||||
|         # Parameter initialisiation | ||||
|         self.parameters = parameters | ||||
|         if hours is None: | ||||
|             self.hours = self.total_hours | ||||
|         else: | ||||
|             self.hours = hours | ||||
|         self.parameters: Optional[HomeApplianceParameters] = None | ||||
|         super().__init__(parameters) | ||||
|  | ||||
|         self.initialised = False | ||||
|         # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. | ||||
|         if self.parameters is not None: | ||||
|             self.setup() | ||||
|  | ||||
|     def setup(self) -> None: | ||||
|         if self.initialised: | ||||
|             return | ||||
|         if self.provider_id is not None: | ||||
|             # Setup by configuration | ||||
|             self.hours = self.total_hours | ||||
|             self.consumption_wh = getattr(self.config, f"{self.prefix}_consumption") | ||||
|             self.duration_h = getattr(self.config, f"{self.prefix}_duration") | ||||
|         elif self.parameters is not None: | ||||
|             # Setup by parameters | ||||
|             self.consumption_wh = ( | ||||
|                 self.parameters.consumption_wh | ||||
|             )  # Total energy consumption of the device in kWh | ||||
|             self.duration_h = self.parameters.duration_h  # Duration of use in hours | ||||
|         else: | ||||
|             error_msg = "Parameters and provider ID missing. Can't instantiate." | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|     def _setup(self) -> None: | ||||
|         assert self.parameters is not None | ||||
|         self.load_curve = np.zeros(self.hours)  # Initialize the load curve with zeros | ||||
|         self.initialised = True | ||||
|         self.duration_h = self.parameters.duration_h | ||||
|         self.consumption_wh = self.parameters.consumption_wh | ||||
|  | ||||
|     def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None: | ||||
|         """Sets the start time of the device and generates the corresponding load curve. | ||||
|   | ||||
| @@ -1,64 +1,44 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import Field | ||||
| from scipy.interpolate import RegularGridInterpolator | ||||
|  | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.core.pydantic import ParametersBaseModel | ||||
| from akkudoktoreos.devices.battery import Battery | ||||
| from akkudoktoreos.devices.devicesabc import DeviceBase | ||||
| from akkudoktoreos.devices.devicesabc import DeviceBase, DeviceParameters | ||||
| from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| class InverterParameters(ParametersBaseModel): | ||||
| class InverterParameters(DeviceParameters): | ||||
|     device_id: str = Field(description="ID of inverter") | ||||
|     max_power_wh: float = Field(gt=0) | ||||
|     battery: Optional[str] = Field(default=None, description="ID of battery") | ||||
|  | ||||
|  | ||||
| class Inverter(DeviceBase): | ||||
|     def __init__( | ||||
|         self, | ||||
|         self_consumption_predictor: RegularGridInterpolator, | ||||
|         parameters: Optional[InverterParameters] = None, | ||||
|         battery: Optional[Battery] = None, | ||||
|         provider_id: Optional[str] = None, | ||||
|     ): | ||||
|         # Configuration initialisation | ||||
|         self.provider_id = provider_id | ||||
|         self.prefix = "<invalid>" | ||||
|         if self.provider_id == "GenericInverter": | ||||
|             self.prefix = "inverter" | ||||
|         # Parameter initialisiation | ||||
|         self.parameters = parameters | ||||
|         if battery is None: | ||||
|         self.parameters: Optional[InverterParameters] = None | ||||
|         super().__init__(parameters) | ||||
|  | ||||
|     def _setup(self) -> None: | ||||
|         assert self.parameters is not None | ||||
|         if self.parameters.battery is None: | ||||
|             # For the moment raise exception | ||||
|             # TODO: Make battery configurable by config | ||||
|             error_msg = "Battery for PV inverter is mandatory." | ||||
|             logger.error(error_msg) | ||||
|             raise NotImplementedError(error_msg) | ||||
|         self.battery = battery  # Connection to a battery object | ||||
|         self.self_consumption_predictor = self_consumption_predictor | ||||
|         self.self_consumption_predictor = get_eos_load_interpolator() | ||||
|         self.max_power_wh = ( | ||||
|             self.parameters.max_power_wh | ||||
|         )  # Maximum power that the inverter can handle | ||||
|  | ||||
|         self.initialised = False | ||||
|         # Run setup if parameters are given, otherwise setup() has to be called later when the config is initialised. | ||||
|         if self.parameters is not None: | ||||
|             self.setup() | ||||
|  | ||||
|     def setup(self) -> None: | ||||
|         if self.initialised: | ||||
|             return | ||||
|         if self.provider_id is not None: | ||||
|             # Setup by configuration | ||||
|             self.max_power_wh = getattr(self.config, f"{self.prefix}_power_max") | ||||
|         elif self.parameters is not None: | ||||
|             # Setup by parameters | ||||
|             self.max_power_wh = ( | ||||
|                 self.parameters.max_power_wh  # Maximum power that the inverter can handle | ||||
|             ) | ||||
|         else: | ||||
|             error_msg = "Parameters and provider ID missing. Can't instantiate." | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|     def _post_setup(self) -> None: | ||||
|         assert self.parameters is not None | ||||
|         self.battery = self.devices.get_device_by_id(self.parameters.battery) | ||||
|  | ||||
|     def process_energy( | ||||
|         self, generation: float, consumption: float, hour: int | ||||
|   | ||||
							
								
								
									
										25
									
								
								src/akkudoktoreos/devices/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/akkudoktoreos/devices/settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import Field | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.devices.battery import BaseBatteryParameters | ||||
| from akkudoktoreos.devices.generic import HomeApplianceParameters | ||||
| from akkudoktoreos.devices.inverter import InverterParameters | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| class DevicesCommonSettings(SettingsBaseModel): | ||||
|     """Base configuration for devices simulation settings.""" | ||||
|  | ||||
|     batteries: Optional[list[BaseBatteryParameters]] = Field( | ||||
|         default=None, description="List of battery/ev devices" | ||||
|     ) | ||||
|     inverters: Optional[list[InverterParameters]] = Field( | ||||
|         default=None, description="List of inverters" | ||||
|     ) | ||||
|     home_appliances: Optional[list[HomeApplianceParameters]] = Field( | ||||
|         default=None, description="List of home appliances" | ||||
|     ) | ||||
| @@ -106,6 +106,11 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence): | ||||
|         "measurement_load", | ||||
|     ] | ||||
|  | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def _interval_count( | ||||
|         self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration | ||||
|     ) -> int: | ||||
| @@ -143,11 +148,16 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence): | ||||
|         if topic not in self.topics: | ||||
|             return None | ||||
|  | ||||
|         topic_keys = [key for key in self.config.config_keys if key.startswith(topic)] | ||||
|         topic_keys = [ | ||||
|             key for key in self.config.measurement.model_fields.keys() if key.startswith(topic) | ||||
|         ] | ||||
|         key = None | ||||
|         if topic == "measurement_load": | ||||
|             for config_key in topic_keys: | ||||
|                 if config_key.endswith("_name") and getattr(self.config, config_key) == name: | ||||
|                 if ( | ||||
|                     config_key.endswith("_name") | ||||
|                     and getattr(self.config.measurement, config_key) == name | ||||
|                 ): | ||||
|                     key = topic + config_key[len(topic) : len(topic) + 1] + "_mr" | ||||
|                     break | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import logging | ||||
| import random | ||||
| import time | ||||
| from pathlib import Path | ||||
| from typing import Any, Optional | ||||
|  | ||||
| import numpy as np | ||||
| @@ -25,7 +24,6 @@ from akkudoktoreos.devices.battery import ( | ||||
| ) | ||||
| from akkudoktoreos.devices.generic import HomeAppliance, HomeApplianceParameters | ||||
| from akkudoktoreos.devices.inverter import Inverter, InverterParameters | ||||
| from akkudoktoreos.prediction.interpolator import SelfConsumptionProbabilityInterpolator | ||||
| from akkudoktoreos.utils.utils import NumpyEncoder | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| @@ -112,8 +110,12 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|     ): | ||||
|         """Initialize the optimization problem with the required parameters.""" | ||||
|         self.opti_param: dict[str, Any] = {} | ||||
|         self.fixed_eauto_hours = self.config.prediction_hours - self.config.optimization_hours | ||||
|         self.possible_charge_values = self.config.optimization_ev_available_charge_rates_percent | ||||
|         self.fixed_eauto_hours = ( | ||||
|             self.config.prediction.prediction_hours - self.config.optimization.optimization_hours | ||||
|         ) | ||||
|         self.possible_charge_values = ( | ||||
|             self.config.optimization.optimization_ev_available_charge_rates_percent | ||||
|         ) | ||||
|         self.verbose = verbose | ||||
|         self.fix_seed = fixed_seed | ||||
|         self.optimize_ev = True | ||||
| @@ -180,25 +182,27 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|             total_states = 3 * len_ac | ||||
|  | ||||
|         # 1. Mutating the charge_discharge part | ||||
|         charge_discharge_part = individual[: self.config.prediction_hours] | ||||
|         charge_discharge_part = individual[: self.config.prediction.prediction_hours] | ||||
|         (charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part) | ||||
|  | ||||
|         # Instead of a fixed clamping to 0..8 or 0..6 dynamically: | ||||
|         charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1) | ||||
|         individual[: self.config.prediction_hours] = charge_discharge_mutated | ||||
|         individual[: self.config.prediction.prediction_hours] = charge_discharge_mutated | ||||
|  | ||||
|         # 2. Mutating the EV charge part, if active | ||||
|         if self.optimize_ev: | ||||
|             ev_charge_part = individual[ | ||||
|                 self.config.prediction_hours : self.config.prediction_hours * 2 | ||||
|                 self.config.prediction.prediction_hours : self.config.prediction.prediction_hours | ||||
|                 * 2 | ||||
|             ] | ||||
|             (ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part) | ||||
|             ev_charge_part_mutated[self.config.prediction_hours - self.fixed_eauto_hours :] = [ | ||||
|                 0 | ||||
|             ] * self.fixed_eauto_hours | ||||
|             individual[self.config.prediction_hours : self.config.prediction_hours * 2] = ( | ||||
|                 ev_charge_part_mutated | ||||
|             ) | ||||
|             ev_charge_part_mutated[ | ||||
|                 self.config.prediction.prediction_hours - self.fixed_eauto_hours : | ||||
|             ] = [0] * self.fixed_eauto_hours | ||||
|             individual[ | ||||
|                 self.config.prediction.prediction_hours : self.config.prediction.prediction_hours | ||||
|                 * 2 | ||||
|             ] = ev_charge_part_mutated | ||||
|  | ||||
|         # 3. Mutating the appliance start time, if applicable | ||||
|         if self.opti_param["home_appliance"] > 0: | ||||
| @@ -212,13 +216,15 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|     def create_individual(self) -> list[int]: | ||||
|         # Start with discharge states for the individual | ||||
|         individual_components = [ | ||||
|             self.toolbox.attr_discharge_state() for _ in range(self.config.prediction_hours) | ||||
|             self.toolbox.attr_discharge_state() | ||||
|             for _ in range(self.config.prediction.prediction_hours) | ||||
|         ] | ||||
|  | ||||
|         # Add EV charge index values if optimize_ev is True | ||||
|         if self.optimize_ev: | ||||
|             individual_components += [ | ||||
|                 self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction_hours) | ||||
|                 self.toolbox.attr_ev_charge_index() | ||||
|                 for _ in range(self.config.prediction.prediction_hours) | ||||
|             ] | ||||
|  | ||||
|         # Add the start time of the household appliance if it's being optimized | ||||
| @@ -251,7 +257,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|             individual.extend(eautocharge_hours_index.tolist()) | ||||
|         elif self.optimize_ev: | ||||
|             # Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu | ||||
|             individual.extend([0] * self.config.prediction_hours) | ||||
|             individual.extend([0] * self.config.prediction.prediction_hours) | ||||
|  | ||||
|         # Add dishwasher start time if applicable | ||||
|         if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None: | ||||
| @@ -273,12 +279,17 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|         3. Dishwasher start time (integer if applicable). | ||||
|         """ | ||||
|         # Discharge hours as a NumPy array of ints | ||||
|         discharge_hours_bin = np.array(individual[: self.config.prediction_hours], dtype=int) | ||||
|         discharge_hours_bin = np.array( | ||||
|             individual[: self.config.prediction.prediction_hours], dtype=int | ||||
|         ) | ||||
|  | ||||
|         # EV charge hours as a NumPy array of ints (if optimize_ev is True) | ||||
|         eautocharge_hours_index = ( | ||||
|             np.array( | ||||
|                 individual[self.config.prediction_hours : self.config.prediction_hours * 2], | ||||
|                 individual[ | ||||
|                     self.config.prediction.prediction_hours : self.config.prediction.prediction_hours | ||||
|                     * 2 | ||||
|                 ], | ||||
|                 dtype=int, | ||||
|             ) | ||||
|             if self.optimize_ev | ||||
| @@ -390,7 +401,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|             ) | ||||
|             self.ems.set_ev_charge_hours(eautocharge_hours_float) | ||||
|         else: | ||||
|             self.ems.set_ev_charge_hours(np.full(self.config.prediction_hours, 0)) | ||||
|             self.ems.set_ev_charge_hours(np.full(self.config.prediction.prediction_hours, 0)) | ||||
|  | ||||
|         return self.ems.simulate(self.ems.start_datetime.hour) | ||||
|  | ||||
| @@ -452,7 +463,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|         #     min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size) | ||||
|         #     battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:] | ||||
|         #     discharge_hours_bin_tail = discharge_hours_bin[-min_length:] | ||||
|         #     len_ac = len(self.config.optimization_ev_available_charge_rates_percent) | ||||
|         #     len_ac = len(self.config.optimization.optimization_ev_available_charge_rates_percent) | ||||
|  | ||||
|         #     # # Find hours where battery SoC is 0 | ||||
|         #     # zero_soc_mask = battery_soc_per_hour_tail == 0 | ||||
| @@ -501,7 +512,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|                     if parameters.eauto and self.ems.ev | ||||
|                     else 0 | ||||
|                 ) | ||||
|                 * self.config.optimization_penalty, | ||||
|                 * self.config.optimization.optimization_penalty, | ||||
|             ) | ||||
|  | ||||
|         return (gesamtbilanz,) | ||||
| @@ -569,30 +580,26 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|             start_hour = self.ems.start_datetime.hour | ||||
|  | ||||
|         einspeiseverguetung_euro_pro_wh = np.full( | ||||
|             self.config.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh | ||||
|             self.config.prediction.prediction_hours, parameters.ems.einspeiseverguetung_euro_pro_wh | ||||
|         ) | ||||
|  | ||||
|         # 1h Load to Sub 1h Load Distribution -> SelfConsumptionRate | ||||
|         sc = SelfConsumptionProbabilityInterpolator( | ||||
|             Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl" | ||||
|         ) | ||||
|         # TODO: Refactor device setup phase out | ||||
|         self.devices.reset() | ||||
|  | ||||
|         # Initialize PV and EV batteries | ||||
|         akku: Optional[Battery] = None | ||||
|         if parameters.pv_akku: | ||||
|             akku = Battery( | ||||
|                 parameters.pv_akku, | ||||
|                 hours=self.config.prediction_hours, | ||||
|             ) | ||||
|             akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) | ||||
|             akku = Battery(parameters.pv_akku) | ||||
|             self.devices.add_device(akku) | ||||
|             akku.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1)) | ||||
|  | ||||
|         eauto: Optional[Battery] = None | ||||
|         if parameters.eauto: | ||||
|             eauto = Battery( | ||||
|                 parameters.eauto, | ||||
|                 hours=self.config.prediction_hours, | ||||
|             ) | ||||
|             eauto.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) | ||||
|             self.devices.add_device(eauto) | ||||
|             eauto.set_charge_per_hour(np.full(self.config.prediction.prediction_hours, 1)) | ||||
|             self.optimize_ev = ( | ||||
|                 parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0 | ||||
|             ) | ||||
| @@ -603,20 +610,22 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi | ||||
|         dishwasher = ( | ||||
|             HomeAppliance( | ||||
|                 parameters=parameters.dishwasher, | ||||
|                 hours=self.config.prediction_hours, | ||||
|             ) | ||||
|             if parameters.dishwasher is not None | ||||
|             else None | ||||
|         ) | ||||
|         self.devices.add_device(dishwasher) | ||||
|  | ||||
|         # Initialize the inverter and energy management system | ||||
|         inverter: Optional[Inverter] = None | ||||
|         if parameters.inverter: | ||||
|             inverter = Inverter( | ||||
|                 sc, | ||||
|                 parameters.inverter, | ||||
|                 akku, | ||||
|             ) | ||||
|             self.devices.add_device(inverter) | ||||
|  | ||||
|         self.devices.post_setup() | ||||
|  | ||||
|         self.ems.set_parameters( | ||||
|             parameters.ems, | ||||
|             inverter=inverter, | ||||
|   | ||||
| @@ -16,7 +16,7 @@ class OptimizationCommonSettings(SettingsBaseModel): | ||||
|     """ | ||||
|  | ||||
|     optimization_hours: Optional[int] = Field( | ||||
|         default=24, ge=0, description="Number of hours into the future for optimizations." | ||||
|         default=48, ge=0, description="Number of hours into the future for optimizations." | ||||
|     ) | ||||
|  | ||||
|     optimization_penalty: Optional[int] = Field( | ||||
|   | ||||
| @@ -3,6 +3,7 @@ from typing import Optional | ||||
| from pydantic import Field | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings | ||||
|  | ||||
|  | ||||
| class ElecPriceCommonSettings(SettingsBaseModel): | ||||
| @@ -12,3 +13,5 @@ class ElecPriceCommonSettings(SettingsBaseModel): | ||||
|     elecprice_charges_kwh: Optional[float] = Field( | ||||
|         default=None, ge=0, description="Electricity price charges (€/kWh)." | ||||
|     ) | ||||
|  | ||||
|     provider_settings: Optional[ElecPriceImportCommonSettings] = None | ||||
|   | ||||
| @@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider): | ||||
|         return "ElecPriceProvider" | ||||
|  | ||||
|     def enabled(self) -> bool: | ||||
|         return self.provider_id() == self.config.elecprice_provider | ||||
|         return self.provider_id() == self.config.elecprice.elecprice_provider | ||||
|   | ||||
| @@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider): | ||||
|         # Try to take data from 5 weeks back for prediction | ||||
|         date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD") | ||||
|         last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") | ||||
|         url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}" | ||||
|         url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.prediction.timezone}" | ||||
|         response = requests.get(url) | ||||
|         logger.debug(f"Response from {url}: {response}") | ||||
|         response.raise_for_status()  # Raise an error for bad responses | ||||
|         akkudoktor_data = self._validate_data(response.content) | ||||
|         # We are working on fresh data (no cache), report update time | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.timezone) | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) | ||||
|         return akkudoktor_data | ||||
|  | ||||
|     def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: | ||||
| @@ -156,13 +156,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider): | ||||
|         # in ascending order and have the same timestamps. | ||||
|  | ||||
|         # Get elecprice_charges_kwh in wh | ||||
|         charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000 | ||||
|         charges_wh = (self.config.elecprice.elecprice_charges_kwh or 0) / 1000 | ||||
|  | ||||
|         highest_orig_datetime = None  # newest datetime from the api after that we want to update. | ||||
|         series_data = pd.Series(dtype=float)  # Initialize an empty series | ||||
|  | ||||
|         for value in akkudoktor_data.values: | ||||
|             orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone) | ||||
|             orig_datetime = to_datetime(value.start, in_timezone=self.config.prediction.timezone) | ||||
|             if highest_orig_datetime is None or orig_datetime > highest_orig_datetime: | ||||
|                 highest_orig_datetime = orig_datetime | ||||
|  | ||||
| @@ -184,14 +184,14 @@ class ElecPriceAkkudoktor(ElecPriceProvider): | ||||
|  | ||||
|         # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours | ||||
|         needed_prediction_hours = int( | ||||
|             self.config.prediction_hours | ||||
|             self.config.prediction.prediction_hours | ||||
|             - ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600) | ||||
|         ) | ||||
|  | ||||
|         if needed_prediction_hours <= 0: | ||||
|             logger.warning( | ||||
|                 f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" | ||||
|             )  # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records | ||||
|                 f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}" | ||||
|             )  # this might keep data longer than self.start_datetime + self.config.prediction.prediction_hours in the records | ||||
|             return | ||||
|  | ||||
|         if amount_datasets > 800:  # we do the full ets with seasons of 1 week | ||||
|   | ||||
| @@ -62,7 +62,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider): | ||||
|         return "ElecPriceImport" | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
|         if self.config.elecpriceimport_file_path is not None: | ||||
|             self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice") | ||||
|         if self.config.elecpriceimport_json is not None: | ||||
|             self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice") | ||||
|         if self.config.elecprice.provider_settings.elecpriceimport_file_path is not None: | ||||
|             self.import_from_file( | ||||
|                 self.config.elecprice.provider_settings.elecpriceimport_file_path, | ||||
|                 key_prefix="elecprice", | ||||
|             ) | ||||
|         if self.config.elecprice.provider_settings.elecpriceimport_json is not None: | ||||
|             self.import_from_json( | ||||
|                 self.config.elecprice.provider_settings.elecpriceimport_json, key_prefix="elecprice" | ||||
|             ) | ||||
|   | ||||
| @@ -6,6 +6,8 @@ from pathlib import Path | ||||
| import numpy as np | ||||
| from scipy.interpolate import RegularGridInterpolator | ||||
|  | ||||
| from akkudoktoreos.core.coreabc import SingletonMixin | ||||
|  | ||||
|  | ||||
| class SelfConsumptionProbabilityInterpolator: | ||||
|     def __init__(self, filepath: str | Path): | ||||
| @@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator: | ||||
|     #     return self_consumption_rate | ||||
|  | ||||
|  | ||||
| # Test the function | ||||
| # print(calculate_self_consumption(1000, 1200)) | ||||
| class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin): | ||||
|     def __init__(self) -> None: | ||||
|         if hasattr(self, "_initialized"): | ||||
|             return | ||||
|         filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl" | ||||
|         super().__init__(filename) | ||||
|  | ||||
|  | ||||
| # Initialize the Energy Management System, it is a singleton. | ||||
| eos_load_interpolator = EOSLoadInterpolator() | ||||
|  | ||||
|  | ||||
| def get_eos_load_interpolator() -> EOSLoadInterpolator: | ||||
|     return eos_load_interpolator | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| """Load forecast module for load predictions.""" | ||||
|  | ||||
| from typing import Optional | ||||
| from typing import Optional, Union | ||||
|  | ||||
| from pydantic import Field | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings | ||||
| from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| @@ -16,3 +18,7 @@ class LoadCommonSettings(SettingsBaseModel): | ||||
|     load_provider: Optional[str] = Field( | ||||
|         default=None, description="Load provider id of provider to be used." | ||||
|     ) | ||||
|  | ||||
|     provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = ( | ||||
|         None | ||||
|     ) | ||||
|   | ||||
| @@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider): | ||||
|         return "LoadProvider" | ||||
|  | ||||
|     def enabled(self) -> bool: | ||||
|         return self.provider_id() == self.config.load_provider | ||||
|         return self.provider_id() == self.config.load.load_provider | ||||
|   | ||||
| @@ -91,7 +91,9 @@ class LoadAkkudoktor(LoadProvider): | ||||
|                 list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"])) | ||||
|             ) | ||||
|             # Calculate values in W by relative profile data and yearly consumption given in kWh | ||||
|             data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000 | ||||
|             data_year_energy = ( | ||||
|                 profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000 | ||||
|             ) | ||||
|         except FileNotFoundError: | ||||
|             error_msg = f"Error: File {load_file} not found." | ||||
|             logger.error(error_msg) | ||||
| @@ -109,7 +111,7 @@ class LoadAkkudoktor(LoadProvider): | ||||
|         # We provide prediction starting at start of day, to be compatible to old system. | ||||
|         # End date for prediction is prediction hours from now. | ||||
|         date = self.start_datetime.start_of("day") | ||||
|         end_date = self.start_datetime.add(hours=self.config.prediction_hours) | ||||
|         end_date = self.start_datetime.add(hours=self.config.prediction.prediction_hours) | ||||
|         while compare_datetimes(date, end_date).lt: | ||||
|             # Extract mean (index 0) and standard deviation (index 1) for the given day and hour | ||||
|             # Day indexing starts at 0, -1 because of that | ||||
| @@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider): | ||||
|             self.update_value(date, values) | ||||
|             date += to_duration("1 hour") | ||||
|         # We are working on fresh data (no cache), report update time | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.timezone) | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) | ||||
|   | ||||
| @@ -58,7 +58,11 @@ class LoadImport(LoadProvider, PredictionImportProvider): | ||||
|         return "LoadImport" | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
|         if self.config.load_import_file_path is not None: | ||||
|             self.import_from_file(self.config.load_import_file_path, key_prefix="load") | ||||
|         if self.config.load_import_json is not None: | ||||
|             self.import_from_json(self.config.load_import_json, key_prefix="load") | ||||
|         if self.config.load.provider_settings.load_import_file_path is not None: | ||||
|             self.import_from_file( | ||||
|                 self.config.provider_settings.load_import_file_path, key_prefix="load" | ||||
|             ) | ||||
|         if self.config.load.provider_settings.load_import_json is not None: | ||||
|             self.import_from_json( | ||||
|                 self.config.load.provider_settings.load_import_json, key_prefix="load" | ||||
|             ) | ||||
|   | ||||
| @@ -80,13 +80,13 @@ class PredictionCommonSettings(SettingsBaseModel): | ||||
|         description="Number of hours into the past for historical predictions data", | ||||
|     ) | ||||
|     latitude: Optional[float] = Field( | ||||
|         default=None, | ||||
|         default=52.52, | ||||
|         ge=-90.0, | ||||
|         le=90.0, | ||||
|         description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)", | ||||
|     ) | ||||
|     longitude: Optional[float] = Field( | ||||
|         default=None, | ||||
|         default=13.405, | ||||
|         ge=-180.0, | ||||
|         le=180.0, | ||||
|         description="Longitude in decimal degrees, within -180 to 180 (°)", | ||||
|   | ||||
| @@ -121,9 +121,9 @@ class PredictionStartEndKeepMixin(PredictionBase): | ||||
|         Returns: | ||||
|             Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing. | ||||
|         """ | ||||
|         if self.start_datetime and self.config.prediction_hours: | ||||
|         if self.start_datetime and self.config.prediction.prediction_hours: | ||||
|             end_datetime = self.start_datetime + to_duration( | ||||
|                 f"{self.config.prediction_hours} hours" | ||||
|                 f"{self.config.prediction.prediction_hours} hours" | ||||
|             ) | ||||
|             dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours | ||||
|             logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}") | ||||
| @@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase): | ||||
|             return None | ||||
|         historic_hours = self.historic_hours_min() | ||||
|         if ( | ||||
|             self.config.prediction_historic_hours | ||||
|             and self.config.prediction_historic_hours > historic_hours | ||||
|             self.config.prediction.prediction_historic_hours | ||||
|             and self.config.prediction.prediction_historic_hours > historic_hours | ||||
|         ): | ||||
|             historic_hours = int(self.config.prediction_historic_hours) | ||||
|             historic_hours = int(self.config.prediction.prediction_historic_hours) | ||||
|         return self.start_datetime - to_duration(f"{historic_hours} hours") | ||||
|  | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|   | ||||
| @@ -6,6 +6,7 @@ from pydantic import Field, computed_field | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| @@ -260,7 +261,7 @@ class PVForecastCommonSettings(SettingsBaseModel): | ||||
|         default=None, description="Nominal power of PV system in kW." | ||||
|     ) | ||||
|     pvforecast4_pvtechchoice: Optional[str] = Field( | ||||
|         "crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." | ||||
|         default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." | ||||
|     ) | ||||
|     pvforecast4_mountingplace: Optional[str] = Field( | ||||
|         default="free", | ||||
| @@ -316,7 +317,7 @@ class PVForecastCommonSettings(SettingsBaseModel): | ||||
|         default=None, description="Nominal power of PV system in kW." | ||||
|     ) | ||||
|     pvforecast5_pvtechchoice: Optional[str] = Field( | ||||
|         "crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." | ||||
|         default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'." | ||||
|     ) | ||||
|     pvforecast5_mountingplace: Optional[str] = Field( | ||||
|         default="free", | ||||
| @@ -359,6 +360,8 @@ class PVForecastCommonSettings(SettingsBaseModel): | ||||
|  | ||||
|     pvforecast_max_planes: ClassVar[int] = 6  # Maximum number of planes that can be set | ||||
|  | ||||
|     provider_settings: Optional[PVForecastImportCommonSettings] = None | ||||
|  | ||||
|     # Computed fields | ||||
|     @computed_field  # type: ignore[prop-decorator] | ||||
|     @property | ||||
|   | ||||
| @@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider): | ||||
|  | ||||
|     def enabled(self) -> bool: | ||||
|         logger.debug( | ||||
|             f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}" | ||||
|             f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.pvforecast_provider}" | ||||
|         ) | ||||
|         return self.provider_id() == self.config.pvforecast_provider | ||||
|         return self.provider_id() == self.config.pvforecast.pvforecast_provider | ||||
|   | ||||
| @@ -203,19 +203,23 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|         """Build akkudoktor.net API request URL.""" | ||||
|         base_url = "https://api.akkudoktor.net/forecast" | ||||
|         query_params = [ | ||||
|             f"lat={self.config.latitude}", | ||||
|             f"lon={self.config.longitude}", | ||||
|             f"lat={self.config.prediction.latitude}", | ||||
|             f"lon={self.config.prediction.longitude}", | ||||
|         ] | ||||
|  | ||||
|         for i in range(len(self.config.pvforecast_planes)): | ||||
|             query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}") | ||||
|             query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}") | ||||
|             query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}") | ||||
|         for i in range(len(self.config.pvforecast.pvforecast_planes)): | ||||
|             query_params.append( | ||||
|                 f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}" | ||||
|                 f"power={int(self.config.pvforecast.pvforecast_planes_peakpower[i] * 1000)}" | ||||
|             ) | ||||
|             query_params.append( | ||||
|                 f"azimuth={int(self.config.pvforecast.pvforecast_planes_azimuth[i])}" | ||||
|             ) | ||||
|             query_params.append(f"tilt={int(self.config.pvforecast.pvforecast_planes_tilt[i])}") | ||||
|             query_params.append( | ||||
|                 f"powerInverter={int(self.config.pvforecast.pvforecast_planes_inverter_paco[i])}" | ||||
|             ) | ||||
|             horizon_values = ",".join( | ||||
|                 str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i] | ||||
|                 str(int(h)) for h in self.config.pvforecast.pvforecast_planes_userhorizon[i] | ||||
|             ) | ||||
|             query_params.append(f"horizont={horizon_values}") | ||||
|  | ||||
| @@ -226,7 +230,7 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|                 "cellCoEff=-0.36", | ||||
|                 "inverterEfficiency=0.8", | ||||
|                 "albedo=0.25", | ||||
|                 f"timezone={self.config.timezone}", | ||||
|                 f"timezone={self.config.prediction.timezone}", | ||||
|                 "hourly=relativehumidity_2m%2Cwindspeed_10m", | ||||
|             ] | ||||
|         ) | ||||
| @@ -255,7 +259,7 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|         logger.debug(f"Response from {self._url()}: {response}") | ||||
|         akkudoktor_data = self._validate_data(response.content) | ||||
|         # We are working on fresh data (no cache), report update time | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.timezone) | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) | ||||
|         return akkudoktor_data | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
| @@ -265,7 +269,7 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|         `PVForecastAkkudoktorDataRecord`. | ||||
|         """ | ||||
|         # Assure we have something to request PV power for. | ||||
|         if not self.config.pvforecast_planes: | ||||
|         if not self.config.pvforecast.pvforecast_planes: | ||||
|             # No planes for PV | ||||
|             error_msg = "Requested PV forecast, but no planes configured." | ||||
|             logger.error(f"Configuration error: {error_msg}") | ||||
| @@ -275,17 +279,17 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|         akkudoktor_data = self._request_forecast(force_update=force_update)  # type: ignore | ||||
|  | ||||
|         # Timezone of the PV system | ||||
|         if self.config.timezone != akkudoktor_data.meta.timezone: | ||||
|             error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." | ||||
|         if self.config.prediction.timezone != akkudoktor_data.meta.timezone: | ||||
|             error_msg = f"Configured timezone '{self.config.prediction.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." | ||||
|             logger.error(f"Akkudoktor schema change: {error_msg}") | ||||
|             raise ValueError(error_msg) | ||||
|  | ||||
|         # Assumption that all lists are the same length and are ordered chronologically | ||||
|         # in ascending order and have the same timestamps. | ||||
|         if len(akkudoktor_data.values[0]) < self.config.prediction_hours: | ||||
|         if len(akkudoktor_data.values[0]) < self.config.prediction.prediction_hours: | ||||
|             # Expect one value set per prediction hour | ||||
|             error_msg = ( | ||||
|                 f"The forecast must cover at least {self.config.prediction_hours} hours, " | ||||
|                 f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, " | ||||
|                 f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data." | ||||
|             ) | ||||
|             logger.error(f"Akkudoktor schema change: {error_msg}") | ||||
| @@ -296,7 +300,7 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|         # Iterate over forecast data points | ||||
|         for forecast_values in zip(*akkudoktor_data.values): | ||||
|             original_datetime = forecast_values[0].datetime | ||||
|             dt = to_datetime(original_datetime, in_timezone=self.config.timezone) | ||||
|             dt = to_datetime(original_datetime, in_timezone=self.config.prediction.timezone) | ||||
|  | ||||
|             # Skip outdated forecast data | ||||
|             if compare_datetimes(dt, self.start_datetime.start_of("day")).lt: | ||||
| @@ -314,9 +318,9 @@ class PVForecastAkkudoktor(PVForecastProvider): | ||||
|  | ||||
|             self.update_value(dt, data) | ||||
|  | ||||
|         if len(self) < self.config.prediction_hours: | ||||
|         if len(self) < self.config.prediction.prediction_hours: | ||||
|             raise ValueError( | ||||
|                 f"The forecast must cover at least {self.config.prediction_hours} hours, " | ||||
|                 f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, " | ||||
|                 f"but only {len(self)} hours starting from {self.start_datetime} " | ||||
|                 f"were predicted." | ||||
|             ) | ||||
| @@ -365,31 +369,35 @@ if __name__ == "__main__": | ||||
|     """ | ||||
|     # Set up the configuration with necessary fields for URL generation | ||||
|     settings_data = { | ||||
|         "prediction_hours": 48, | ||||
|         "prediction_historic_hours": 24, | ||||
|         "latitude": 52.52, | ||||
|         "longitude": 13.405, | ||||
|         "pvforecast_provider": "PVForecastAkkudoktor", | ||||
|         "pvforecast0_peakpower": 5.0, | ||||
|         "pvforecast0_surface_azimuth": -10, | ||||
|         "pvforecast0_surface_tilt": 7, | ||||
|         "pvforecast0_userhorizon": [20, 27, 22, 20], | ||||
|         "pvforecast0_inverter_paco": 10000, | ||||
|         "pvforecast1_peakpower": 4.8, | ||||
|         "pvforecast1_surface_azimuth": -90, | ||||
|         "pvforecast1_surface_tilt": 7, | ||||
|         "pvforecast1_userhorizon": [30, 30, 30, 50], | ||||
|         "pvforecast1_inverter_paco": 10000, | ||||
|         "pvforecast2_peakpower": 1.4, | ||||
|         "pvforecast2_surface_azimuth": -40, | ||||
|         "pvforecast2_surface_tilt": 60, | ||||
|         "pvforecast2_userhorizon": [60, 30, 0, 30], | ||||
|         "pvforecast2_inverter_paco": 2000, | ||||
|         "pvforecast3_peakpower": 1.6, | ||||
|         "pvforecast3_surface_azimuth": 5, | ||||
|         "pvforecast3_surface_tilt": 45, | ||||
|         "pvforecast3_userhorizon": [45, 25, 30, 60], | ||||
|         "pvforecast3_inverter_paco": 1400, | ||||
|         "prediction": { | ||||
|             "prediction_hours": 48, | ||||
|             "prediction_historic_hours": 24, | ||||
|             "latitude": 52.52, | ||||
|             "longitude": 13.405, | ||||
|         }, | ||||
|         "pvforecast": { | ||||
|             "pvforecast_provider": "PVForecastAkkudoktor", | ||||
|             "pvforecast0_peakpower": 5.0, | ||||
|             "pvforecast0_surface_azimuth": -10, | ||||
|             "pvforecast0_surface_tilt": 7, | ||||
|             "pvforecast0_userhorizon": [20, 27, 22, 20], | ||||
|             "pvforecast0_inverter_paco": 10000, | ||||
|             "pvforecast1_peakpower": 4.8, | ||||
|             "pvforecast1_surface_azimuth": -90, | ||||
|             "pvforecast1_surface_tilt": 7, | ||||
|             "pvforecast1_userhorizon": [30, 30, 30, 50], | ||||
|             "pvforecast1_inverter_paco": 10000, | ||||
|             "pvforecast2_peakpower": 1.4, | ||||
|             "pvforecast2_surface_azimuth": -40, | ||||
|             "pvforecast2_surface_tilt": 60, | ||||
|             "pvforecast2_userhorizon": [60, 30, 0, 30], | ||||
|             "pvforecast2_inverter_paco": 2000, | ||||
|             "pvforecast3_peakpower": 1.6, | ||||
|             "pvforecast3_surface_azimuth": 5, | ||||
|             "pvforecast3_surface_tilt": 45, | ||||
|             "pvforecast3_userhorizon": [45, 25, 30, 60], | ||||
|             "pvforecast3_inverter_paco": 1400, | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     # Initialize the forecast object with the generated configuration | ||||
|   | ||||
| @@ -62,7 +62,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider): | ||||
|         return "PVForecastImport" | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
|         if self.config.pvforecastimport_file_path is not None: | ||||
|             self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast") | ||||
|         if self.config.pvforecastimport_json is not None: | ||||
|             self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast") | ||||
|         if self.config.pvforecast.provider_settings.pvforecastimport_file_path is not None: | ||||
|             self.import_from_file( | ||||
|                 self.config.pvforecast.provider_settings.pvforecastimport_file_path, | ||||
|                 key_prefix="pvforecast", | ||||
|             ) | ||||
|         if self.config.pvforecast.provider_settings.pvforecastimport_json is not None: | ||||
|             self.import_from_json( | ||||
|                 self.config.pvforecast.provider_settings.pvforecastimport_json, | ||||
|                 key_prefix="pvforecast", | ||||
|             ) | ||||
|   | ||||
| @@ -5,9 +5,12 @@ from typing import Optional | ||||
| from pydantic import Field | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings | ||||
|  | ||||
|  | ||||
| class WeatherCommonSettings(SettingsBaseModel): | ||||
|     weather_provider: Optional[str] = Field( | ||||
|         default=None, description="Weather provider id of provider to be used." | ||||
|     ) | ||||
|  | ||||
|     provider_settings: Optional[WeatherImportCommonSettings] = None | ||||
|   | ||||
| @@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider): | ||||
|         return "WeatherProvider" | ||||
|  | ||||
|     def enabled(self) -> bool: | ||||
|         return self.provider_id() == self.config.weather_provider | ||||
|         return self.provider_id() == self.config.weather.weather_provider | ||||
|  | ||||
|     @classmethod | ||||
|     def estimate_irradiance_from_cloud_cover( | ||||
|   | ||||
| @@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider): | ||||
|         date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD") | ||||
|         last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") | ||||
|         response = requests.get( | ||||
|             f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}" | ||||
|             f"{source}/weather?lat={self.config.prediction.latitude}&lon={self.config.prediction.longitude}&date={date}&last_date={last_date}&tz={self.config.prediction.timezone}" | ||||
|         ) | ||||
|         response.raise_for_status()  # Raise an error for bad responses | ||||
|         logger.debug(f"Response from {source}: {response}") | ||||
| @@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider): | ||||
|             logger.error(error_msg) | ||||
|             raise ValueError(error_msg) | ||||
|         # We are working on fresh data (no cache), report update time | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.timezone) | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) | ||||
|         return brightsky_data | ||||
|  | ||||
|     def _description_to_series(self, description: str) -> pd.Series: | ||||
| @@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider): | ||||
|         description = "Total Clouds (% Sky Obscured)" | ||||
|         cloud_cover = self._description_to_series(description) | ||||
|         ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( | ||||
|             self.config.latitude, self.config.longitude, cloud_cover | ||||
|             self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover | ||||
|         ) | ||||
|  | ||||
|         description = "Global Horizontal Irradiance (W/m2)" | ||||
|   | ||||
| @@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider): | ||||
|             response: Weather forecast request reponse from ClearOutside. | ||||
|         """ | ||||
|         source = "https://clearoutside.com/forecast" | ||||
|         latitude = round(self.config.latitude, 2) | ||||
|         longitude = round(self.config.longitude, 2) | ||||
|         latitude = round(self.config.prediction.latitude, 2) | ||||
|         longitude = round(self.config.prediction.longitude, 2) | ||||
|         response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true") | ||||
|         response.raise_for_status()  # Raise an error for bad responses | ||||
|         logger.debug(f"Response from {source}: {response}") | ||||
|         # We are working on fresh data (no cache), report update time | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.timezone) | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone) | ||||
|         return response | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = None) -> None: | ||||
| @@ -307,7 +307,7 @@ class WeatherClearOutside(WeatherProvider): | ||||
|                 data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"] | ||||
|             ) | ||||
|             ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover( | ||||
|                 self.config.latitude, self.config.longitude, cloud_cover | ||||
|                 self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover | ||||
|             ) | ||||
|  | ||||
|             # Add GHI, DNI, DHI to clearout data | ||||
|   | ||||
| @@ -59,7 +59,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider): | ||||
|         return "WeatherImport" | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
|         if self.config.weatherimport_file_path is not None: | ||||
|             self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather") | ||||
|         if self.config.weatherimport_json is not None: | ||||
|             self.import_from_json(self.config.weatherimport_json, key_prefix="weather") | ||||
|         if self.config.weather.provider_settings.weatherimport_file_path is not None: | ||||
|             self.import_from_file( | ||||
|                 self.config.weather.provider_settings.weatherimport_file_path, key_prefix="weather" | ||||
|             ) | ||||
|         if self.config.weather.provider_settings.weatherimport_json is not None: | ||||
|             self.import_from_json( | ||||
|                 self.config.weather.provider_settings.weatherimport_json, key_prefix="weather" | ||||
|             ) | ||||
|   | ||||
| @@ -29,7 +29,10 @@ from akkudoktoreos.optimization.genetic import ( | ||||
|     OptimizeResponse, | ||||
|     optimization_problem, | ||||
| ) | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
| from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings | ||||
| from akkudoktoreos.prediction.load import LoadCommonSettings | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings | ||||
| from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction | ||||
| from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| @@ -149,16 +152,16 @@ def start_eosdash() -> subprocess.Popen: | ||||
|  | ||||
|     if args is None: | ||||
|         # No command line arguments | ||||
|         host = config_eos.server_eosdash_host | ||||
|         port = config_eos.server_eosdash_port | ||||
|         eos_host = config_eos.server_eos_host | ||||
|         eos_port = config_eos.server_eos_port | ||||
|         host = config_eos.server.server_eosdash_host | ||||
|         port = config_eos.server.server_eosdash_port | ||||
|         eos_host = config_eos.server.server_eos_host | ||||
|         eos_port = config_eos.server.server_eos_port | ||||
|         log_level = "info" | ||||
|         access_log = False | ||||
|         reload = False | ||||
|     else: | ||||
|         host = args.host | ||||
|         port = config_eos.server_eosdash_port if config_eos.server_eosdash_port else (args.port + 1) | ||||
|         port = config_eos.server.server_eosdash_port if config_eos.server.server_eosdash_port else (args.port + 1) | ||||
|         eos_host = args.host | ||||
|         eos_port = args.port | ||||
|         log_level = args.log_level | ||||
| @@ -201,7 +204,7 @@ def start_eosdash() -> subprocess.Popen: | ||||
| async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: | ||||
|     """Lifespan manager for the app.""" | ||||
|     # On startup | ||||
|     if config_eos.server_eos_startup_eosdash: | ||||
|     if config_eos.server.server_eos_startup_eosdash: | ||||
|         try: | ||||
|             eosdash_process = start_eosdash() | ||||
|         except Exception as e: | ||||
| @@ -228,7 +231,7 @@ app = FastAPI( | ||||
|  | ||||
|  | ||||
| # That's the problem | ||||
| opt_class = optimization_problem(verbose=bool(config_eos.server_eos_verbose)) | ||||
| opt_class = optimization_problem(verbose=bool(config_eos.server.server_eos_verbose)) | ||||
|  | ||||
| server_dir = Path(__file__).parent.resolve() | ||||
|  | ||||
| @@ -340,7 +343,7 @@ def fastapi_config_put( | ||||
|         configuration (ConfigEOS): The current configuration after the write. | ||||
|     """ | ||||
|     try: | ||||
|         config_eos.merge_settings(settings, force=True) | ||||
|         config_eos.merge_settings(settings) | ||||
|     except Exception as e: | ||||
|         raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}") | ||||
|     return config_eos | ||||
| @@ -610,7 +613,9 @@ def fastapi_strompreis() -> list[float]: | ||||
|         '/v1/prediction/list?key=elecprice_marketprice_kwh' instead. | ||||
|     """ | ||||
|     settings = SettingsEOS( | ||||
|         elecprice_provider="ElecPriceAkkudoktor", | ||||
|         elecprice=ElecPriceCommonSettings( | ||||
|             elecprice_provider="ElecPriceAkkudoktor", | ||||
|         ) | ||||
|     ) | ||||
|     config_eos.merge_settings(settings=settings) | ||||
|     ems_eos.set_start_datetime()  # Set energy management start datetime to current hour. | ||||
| @@ -660,9 +665,15 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]: | ||||
|         '/v1/measurement/value' | ||||
|     """ | ||||
|     settings = SettingsEOS( | ||||
|         prediction_hours=request.hours, | ||||
|         load_provider="LoadAkkudoktor", | ||||
|         loadakkudoktor_year_energy=request.year_energy, | ||||
|         prediction=PredictionCommonSettings( | ||||
|             prediction_hours=request.hours, | ||||
|         ), | ||||
|         load=LoadCommonSettings( | ||||
|             load_provider="LoadAkkudoktor", | ||||
|             provider_settings=LoadAkkudoktorCommonSettings( | ||||
|                 loadakkudoktor_year_energy=request.year_energy, | ||||
|             ), | ||||
|         ), | ||||
|     ) | ||||
|     config_eos.merge_settings(settings=settings) | ||||
|     ems_eos.set_start_datetime()  # Set energy management start datetime to current hour. | ||||
| @@ -738,8 +749,12 @@ def fastapi_gesamtlast_simple(year_energy: float) -> list[float]: | ||||
|         '/v1/prediction/list?key=load_mean' instead. | ||||
|     """ | ||||
|     settings = SettingsEOS( | ||||
|         load_provider="LoadAkkudoktor", | ||||
|         loadakkudoktor_year_energy=year_energy / 1000,  # Convert to kWh | ||||
|         load=LoadCommonSettings( | ||||
|             load_provider="LoadAkkudoktor", | ||||
|             provider_settings=LoadAkkudoktorCommonSettings( | ||||
|                 loadakkudoktor_year_energy=year_energy / 1000,  # Convert to kWh | ||||
|             ), | ||||
|         ) | ||||
|     ) | ||||
|     config_eos.merge_settings(settings=settings) | ||||
|     ems_eos.set_start_datetime()  # Set energy management start datetime to current hour. | ||||
| @@ -844,7 +859,7 @@ def fastapi_optimize( | ||||
| @app.get("/visualization_results.pdf", response_class=PdfResponse) | ||||
| def get_pdf() -> PdfResponse: | ||||
|     # Endpoint to serve the generated PDF with visualization results | ||||
|     output_path = config_eos.data_output_path | ||||
|     output_path = config_eos.config.data_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" | ||||
| @@ -882,9 +897,9 @@ async def proxy_put(request: Request, path: str) -> Response: | ||||
|  | ||||
|  | ||||
| async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]: | ||||
|     if config_eos.server_eosdash_host and config_eos.server_eosdash_port: | ||||
|     if config_eos.server.server_eosdash_host and config_eos.server.server_eosdash_port: | ||||
|         # Proxy to EOSdash server | ||||
|         url = f"http://{config_eos.server_eosdash_host}:{config_eos.server_eosdash_port}/{path}" | ||||
|         url = f"http://{config_eos.server.server_eosdash_host}:{config_eos.server.server_eosdash_port}/{path}" | ||||
|         headers = dict(request.headers) | ||||
|  | ||||
|         data = await request.body() | ||||
| @@ -984,14 +999,14 @@ def main() -> None: | ||||
|     parser.add_argument( | ||||
|         "--host", | ||||
|         type=str, | ||||
|         default=str(config_eos.server_eos_host), | ||||
|         help="Host for the EOS server (default: value from config_eos)", | ||||
|         default=str(config_eos.server.server_eos_host), | ||||
|         help="Host for the EOS server (default: value from config)", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--port", | ||||
|         type=int, | ||||
|         default=config_eos.server_eos_port, | ||||
|         help="Port for the EOS server (default: value from config_eos)", | ||||
|         default=config_eos.server.server_eos_port, | ||||
|         help="Port for the EOS server (default: value from config)", | ||||
|     ) | ||||
|  | ||||
|     # Optional arguments for log_level, access_log, and reload | ||||
|   | ||||
| @@ -110,13 +110,13 @@ def main() -> None: | ||||
|     parser.add_argument( | ||||
|         "--host", | ||||
|         type=str, | ||||
|         default=str(config_eos.server_eosdash_host), | ||||
|         default=str(config_eos.server.server_eosdash_host), | ||||
|         help="Host for the EOSdash server (default: value from config_eos)", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--port", | ||||
|         type=int, | ||||
|         default=config_eos.server_eosdash_port, | ||||
|         default=config_eos.server.server_eosdash_port, | ||||
|         help="Port for the EOSdash server (default: value from config_eos)", | ||||
|     ) | ||||
|  | ||||
| @@ -124,13 +124,13 @@ def main() -> None: | ||||
|     parser.add_argument( | ||||
|         "--eos-host", | ||||
|         type=str, | ||||
|         default=str(config_eos.server_eos_host), | ||||
|         default=str(config_eos.server.server_eos_host), | ||||
|         help="Host for the EOS server (default: value from config_eos)", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--eos-port", | ||||
|         type=int, | ||||
|         default=config_eos.server_eos_port, | ||||
|         default=config_eos.server.server_eos_port, | ||||
|         help="Port for the EOS server (default: value from config_eos)", | ||||
|     ) | ||||
|  | ||||
|   | ||||
| @@ -329,9 +329,9 @@ class CacheFileStore(ConfigMixin, metaclass=CacheFileStoreMeta): | ||||
|                 # File already available | ||||
|                 cache_file_obj = cache_item.cache_file | ||||
|             else: | ||||
|                 self.config.data_cache_path.mkdir(parents=True, exist_ok=True) | ||||
|                 self.config.general.data_cache_path.mkdir(parents=True, exist_ok=True) | ||||
|                 cache_file_obj = tempfile.NamedTemporaryFile( | ||||
|                     mode=mode, delete=delete, suffix=suffix, dir=self.config.data_cache_path | ||||
|                     mode=mode, delete=delete, suffix=suffix, dir=self.config.general.data_cache_path | ||||
|                 ) | ||||
|                 self._store[cache_file_key] = CacheFileRecord( | ||||
|                     cache_file=cache_file_obj, | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import json | ||||
| from typing import Any | ||||
| from typing import Any, Optional | ||||
|  | ||||
| import numpy as np | ||||
|  | ||||
| @@ -9,6 +9,14 @@ from akkudoktoreos.core.logging import get_logger | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| class classproperty(property): | ||||
|     def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any: | ||||
|         if owner_cls is None: | ||||
|             return self | ||||
|         assert self.fget is not None | ||||
|         return self.fget(owner_cls) | ||||
|  | ||||
|  | ||||
| class UtilsCommonSettings(SettingsBaseModel): | ||||
|     pass | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,7 @@ class VisualizationReport(ConfigMixin): | ||||
|         self.pdf_pages = PdfPages(filename, metadata={})  # Initialize PdfPages without metadata | ||||
|         self.version = version  # overwrite version as test for constant output of pdf for test | ||||
|         self.current_time = to_datetime( | ||||
|             as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.timezone | ||||
|             as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.prediction.timezone | ||||
|         ) | ||||
|  | ||||
|     def add_chart_to_group(self, chart_func: Callable[[], None]) -> None: | ||||
| @@ -51,7 +51,7 @@ class VisualizationReport(ConfigMixin): | ||||
|  | ||||
|     def _initialize_pdf(self) -> None: | ||||
|         """Create the output directory if it doesn't exist and initialize the PDF.""" | ||||
|         output_dir = self.config.data_output_path | ||||
|         output_dir = self.config.general.data_output_path | ||||
|  | ||||
|         # If self.filename is already a valid path, use it; otherwise, combine it with output_dir | ||||
|         if os.path.isabs(self.filename): | ||||
| @@ -173,7 +173,7 @@ class VisualizationReport(ConfigMixin): | ||||
|             plt.grid(True) | ||||
|  | ||||
|             # Add vertical line for the current date if within the axis range | ||||
|             current_time = pendulum.now(self.config.timezone) | ||||
|             current_time = pendulum.now(self.config.prediction.timezone) | ||||
|             if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]: | ||||
|                 plt.axvline(current_time, color="r", linestyle="--", label="Now") | ||||
|                 plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom") | ||||
| @@ -419,7 +419,7 @@ def prepare_visualize( | ||||
|     start_hour: Optional[int] = 0, | ||||
| ) -> None: | ||||
|     report = VisualizationReport(filename) | ||||
|     next_full_hour_date = pendulum.now(report.config.timezone).start_of("hour").add(hours=1) | ||||
|     next_full_hour_date = pendulum.now(report.config.prediction.timezone).start_of("hour").add(hours=1) | ||||
|     # Group 1: | ||||
|     report.create_line_chart_date( | ||||
|         next_full_hour_date,  # start_date | ||||
|   | ||||
		Reference in New Issue
	
	Block a user