2024-12-15 14:40:03 +01:00
|
|
|
"""Abstract and base classes for devices."""
|
|
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
from enum import Enum
|
|
|
|
|
from typing import Optional, Type
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
from pendulum import DateTime
|
2025-01-12 05:19:37 +01:00
|
|
|
from pydantic import Field, computed_field
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
from akkudoktoreos.core.coreabc import (
|
|
|
|
|
ConfigMixin,
|
2025-01-12 05:19:37 +01:00
|
|
|
DevicesMixin,
|
2024-12-15 14:40:03 +01:00
|
|
|
EnergyManagementSystemMixin,
|
|
|
|
|
PredictionMixin,
|
|
|
|
|
)
|
2025-01-05 14:41:07 +01:00
|
|
|
from akkudoktoreos.core.logging import get_logger
|
2025-01-12 05:19:37 +01:00
|
|
|
from akkudoktoreos.core.pydantic import ParametersBaseModel
|
2024-12-15 14:40:03 +01:00
|
|
|
from akkudoktoreos.utils.datetimeutil import to_duration
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
class DeviceParameters(ParametersBaseModel):
|
2025-01-15 00:54:45 +01:00
|
|
|
device_id: str = Field(description="ID of device", examples="device1")
|
2025-01-12 05:19:37 +01:00
|
|
|
hours: Optional[int] = Field(
|
|
|
|
|
default=None,
|
|
|
|
|
gt=0,
|
|
|
|
|
description="Number of prediction hours. Defaults to global config prediction hours.",
|
2025-01-15 00:54:45 +01:00
|
|
|
examples=[None],
|
2025-01-12 05:19:37 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DeviceOptimizeResult(ParametersBaseModel):
|
2025-01-15 00:54:45 +01:00
|
|
|
device_id: str = Field(description="ID of device", examples=["device1"])
|
|
|
|
|
hours: int = Field(gt=0, description="Number of hours in the simulation.", examples=[24])
|
2025-01-12 05:19:37 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class DeviceState(Enum):
|
|
|
|
|
UNINITIALIZED = 0
|
|
|
|
|
PREPARED = 1
|
|
|
|
|
INITIALIZED = 2
|
|
|
|
|
|
|
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
class DevicesStartEndMixin(ConfigMixin, EnergyManagementSystemMixin):
|
|
|
|
|
"""A mixin to manage start, end datetimes for devices data.
|
|
|
|
|
|
|
|
|
|
The starting datetime for devices data generation is provided by the energy management
|
|
|
|
|
system. Device data cannot be computed if this value is `None`.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# Computed field for end_datetime and keep_datetime
|
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
|
@property
|
|
|
|
|
def end_datetime(self) -> Optional[DateTime]:
|
|
|
|
|
"""Compute the end datetime based on the `start_datetime` and `prediction_hours`.
|
|
|
|
|
|
|
|
|
|
Ajusts the calculated end time if DST transitions occur within the prediction window.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
|
|
|
|
|
"""
|
2025-01-12 05:19:37 +01:00
|
|
|
if self.ems.start_datetime and self.config.prediction.prediction_hours:
|
2024-12-15 14:40:03 +01:00
|
|
|
end_datetime = self.ems.start_datetime + to_duration(
|
2025-01-12 05:19:37 +01:00
|
|
|
f"{self.config.prediction.prediction_hours} hours"
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
|
|
|
|
dst_change = end_datetime.offset_hours - self.ems.start_datetime.offset_hours
|
|
|
|
|
logger.debug(
|
|
|
|
|
f"Pre: {self.ems.start_datetime}..{end_datetime}: DST change: {dst_change}"
|
|
|
|
|
)
|
|
|
|
|
if dst_change < 0:
|
|
|
|
|
end_datetime = end_datetime + to_duration(f"{abs(int(dst_change))} hours")
|
|
|
|
|
elif dst_change > 0:
|
|
|
|
|
end_datetime = end_datetime - to_duration(f"{abs(int(dst_change))} hours")
|
|
|
|
|
logger.debug(
|
|
|
|
|
f"Pst: {self.ems.start_datetime}..{end_datetime}: DST change: {dst_change}"
|
|
|
|
|
)
|
|
|
|
|
return end_datetime
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
|
@property
|
|
|
|
|
def total_hours(self) -> Optional[int]:
|
|
|
|
|
"""Compute the hours from `start_datetime` to `end_datetime`.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Optional[pendulum.period]: The duration hours, or `None` if either datetime is unavailable.
|
|
|
|
|
"""
|
|
|
|
|
end_dt = self.end_datetime
|
|
|
|
|
if end_dt is None:
|
|
|
|
|
return None
|
|
|
|
|
duration = end_dt - self.ems.start_datetime
|
|
|
|
|
return int(duration.total_hours())
|
|
|
|
|
|
|
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
class DeviceBase(DevicesStartEndMixin, PredictionMixin, DevicesMixin):
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Base class for device simulations.
|
|
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
Enables access to EOS configuration data (attribute `config`), EOS prediction data (attribute
|
|
|
|
|
`prediction`) and EOS device registry (attribute `devices`).
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
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.
|
2024-12-15 14:40:03 +01:00
|
|
|
"""
|
|
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
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
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
self.parameters = parameters
|
|
|
|
|
self.device_id = self.parameters.device_id
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
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):
|
2024-12-15 14:40:03 +01:00
|
|
|
"""Base class for handling device data.
|
|
|
|
|
|
|
|
|
|
Enables access to EOS configuration data (attribute `config`) and EOS prediction data (attribute
|
|
|
|
|
`prediction`).
|
|
|
|
|
"""
|
|
|
|
|
|
2025-01-12 05:19:37 +01:00
|
|
|
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()
|