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:
Dominique Lasserre
2025-01-12 05:19:37 +01:00
parent f09658578a
commit be26457563
72 changed files with 1297 additions and 1712 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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")

View File

@@ -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.

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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
}

View File

@@ -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 EVs 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,

View File

@@ -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.

View File

@@ -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()

View File

@@ -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.

View File

@@ -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

View 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"
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"
)

View File

@@ -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 (°)",

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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)"

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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)",
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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