2025-03-27 21:53:01 +01:00
|
|
|
import traceback
|
2025-10-28 02:50:31 +01:00
|
|
|
from asyncio import Lock, get_running_loop
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-03-15 13:32:05 +01:00
|
|
|
from enum import StrEnum
|
2025-10-28 02:50:31 +01:00
|
|
|
from functools import partial
|
|
|
|
|
from typing import ClassVar, Optional
|
2024-10-22 10:29:57 +02:00
|
|
|
|
2025-06-10 22:00:28 +02:00
|
|
|
from loguru import logger
|
2025-10-28 02:50:31 +01:00
|
|
|
from pydantic import computed_field
|
2024-10-03 11:05:44 +02:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
2025-12-30 22:08:21 +01:00
|
|
|
from akkudoktoreos.core.coreabc import (
|
|
|
|
|
AdapterMixin,
|
|
|
|
|
ConfigMixin,
|
|
|
|
|
PredictionMixin,
|
|
|
|
|
SingletonMixin,
|
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
from akkudoktoreos.core.emplan import EnergyManagementPlan
|
|
|
|
|
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
|
|
|
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
|
|
|
|
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
|
|
|
|
from akkudoktoreos.optimization.genetic.geneticparams import (
|
|
|
|
|
GeneticOptimizationParameters,
|
|
|
|
|
)
|
|
|
|
|
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
|
|
|
|
from akkudoktoreos.optimization.optimization import OptimizationSolution
|
2026-02-22 14:12:42 +01:00
|
|
|
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
2024-10-22 10:29:57 +02:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# The executor to execute the CPU heavy energy management run
|
|
|
|
|
executor = ThreadPoolExecutor(max_workers=1)
|
2024-11-26 22:28:05 +01:00
|
|
|
|
|
|
|
|
|
2026-03-15 13:32:05 +01:00
|
|
|
class EnergyManagementStage(StrEnum):
|
2025-12-30 22:08:21 +01:00
|
|
|
"""Enumeration of the main stages in the energy management lifecycle."""
|
|
|
|
|
|
|
|
|
|
IDLE = "IDLE"
|
|
|
|
|
DATA_ACQUISITION = "DATA_AQUISITION"
|
|
|
|
|
FORECAST_RETRIEVAL = "FORECAST_RETRIEVAL"
|
|
|
|
|
OPTIMIZATION = "OPTIMIZATION"
|
|
|
|
|
CONTROL_DISPATCH = "CONTROL_DISPATCH"
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 14:12:42 +01:00
|
|
|
async def ems_manage_energy() -> None:
|
|
|
|
|
"""Repeating task for managing energy.
|
|
|
|
|
|
|
|
|
|
This task should be executed by the server regularly
|
|
|
|
|
to ensure proper energy management.
|
|
|
|
|
"""
|
|
|
|
|
await EnergyManagement().run()
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
class EnergyManagement(
|
|
|
|
|
SingletonMixin, ConfigMixin, PredictionMixin, AdapterMixin, PydanticBaseModel
|
|
|
|
|
):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Energy management."""
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
# Start datetime.
|
|
|
|
|
_start_datetime: ClassVar[Optional[DateTime]] = None
|
|
|
|
|
|
2025-02-12 21:35:51 +01:00
|
|
|
# last run datetime. Used by energy management task
|
2025-10-28 02:50:31 +01:00
|
|
|
_last_run_datetime: ClassVar[Optional[DateTime]] = None
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
# Current energy management stage
|
|
|
|
|
_stage: ClassVar[EnergyManagementStage] = EnergyManagementStage.IDLE
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# energy management plan of latest energy management run with optimization
|
|
|
|
|
_plan: ClassVar[Optional[EnergyManagementPlan]] = None
|
|
|
|
|
|
|
|
|
|
# opimization solution of the latest energy management run
|
|
|
|
|
_optimization_solution: ClassVar[Optional[OptimizationSolution]] = None
|
|
|
|
|
|
|
|
|
|
# Solution of the genetic algorithm of latest energy management run with optimization
|
|
|
|
|
# For classic API
|
|
|
|
|
_genetic_solution: ClassVar[Optional[GeneticSolution]] = None
|
|
|
|
|
|
|
|
|
|
# energy management lock (for energy management run)
|
|
|
|
|
_run_lock: ClassVar[Lock] = Lock()
|
2025-02-12 21:35:51 +01:00
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
|
@property
|
|
|
|
|
def start_datetime(self) -> DateTime:
|
|
|
|
|
"""The starting datetime of the current or latest energy management."""
|
2025-02-12 21:35:51 +01:00
|
|
|
if EnergyManagement._start_datetime is None:
|
|
|
|
|
EnergyManagement.set_start_datetime()
|
|
|
|
|
return EnergyManagement._start_datetime
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
|
|
|
@property
|
|
|
|
|
def last_run_datetime(self) -> Optional[DateTime]:
|
|
|
|
|
"""The datetime the last energy management was run."""
|
|
|
|
|
return EnergyManagement._last_run_datetime
|
|
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
@classmethod
|
|
|
|
|
def set_start_datetime(cls, start_datetime: Optional[DateTime] = None) -> DateTime:
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Set the start datetime for the next energy management run.
|
2025-02-12 21:35:51 +01:00
|
|
|
|
|
|
|
|
If no datetime is provided, the current datetime is used.
|
|
|
|
|
|
|
|
|
|
The start datetime is always rounded down to the nearest hour
|
|
|
|
|
(i.e., setting minutes, seconds, and microseconds to zero).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
start_datetime (Optional[DateTime]): The datetime to set as the start.
|
|
|
|
|
If None, the current datetime is used.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
DateTime: The adjusted start datetime.
|
|
|
|
|
"""
|
2024-12-15 14:40:03 +01:00
|
|
|
if start_datetime is None:
|
|
|
|
|
start_datetime = to_datetime()
|
|
|
|
|
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
|
|
|
|
|
return cls._start_datetime
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
@classmethod
|
|
|
|
|
def stage(cls) -> EnergyManagementStage:
|
|
|
|
|
"""Get the the stage of the energy management.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
EnergyManagementStage: The current stage of energy management.
|
|
|
|
|
"""
|
|
|
|
|
return cls._stage
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
@classmethod
|
|
|
|
|
def plan(cls) -> Optional[EnergyManagementPlan]:
|
|
|
|
|
"""Get the latest energy management plan.
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
Returns:
|
|
|
|
|
Optional[EnergyManagementPlan]: The latest energy management plan or None.
|
|
|
|
|
"""
|
|
|
|
|
return cls._plan
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
@classmethod
|
|
|
|
|
def optimization_solution(cls) -> Optional[OptimizationSolution]:
|
|
|
|
|
"""Get the latest optimization solution.
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
Returns:
|
|
|
|
|
Optional[OptimizationSolution]: The latest optimization solution.
|
|
|
|
|
"""
|
|
|
|
|
return cls._optimization_solution
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
@classmethod
|
|
|
|
|
def genetic_solution(cls) -> Optional[GeneticSolution]:
|
|
|
|
|
"""Get the latest solution of the genetic algorithm.
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
Returns:
|
|
|
|
|
Optional[GeneticSolution]: The latest solution of the genetic algorithm.
|
|
|
|
|
"""
|
|
|
|
|
return cls._genetic_solution
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
@classmethod
|
|
|
|
|
def _run(
|
|
|
|
|
cls,
|
2026-03-13 15:48:43 +01:00
|
|
|
start_datetime: DateTime,
|
|
|
|
|
mode: EnergyManagementMode,
|
2025-10-28 02:50:31 +01:00
|
|
|
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
|
|
|
|
|
genetic_individuals: Optional[int] = None,
|
|
|
|
|
genetic_seed: Optional[int] = None,
|
2024-12-15 14:40:03 +01:00
|
|
|
force_enable: Optional[bool] = False,
|
|
|
|
|
force_update: Optional[bool] = False,
|
|
|
|
|
) -> None:
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Run the energy management.
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
This method initializes the energy management run by setting its
|
2025-12-30 22:08:21 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
start datetime, updating predictions, and optionally starting
|
|
|
|
|
optimization depending on the selected mode or configuration.
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
Args:
|
2026-03-13 15:48:43 +01:00
|
|
|
start_datetime (DateTime): The starting timestamp of the energy management run.
|
|
|
|
|
mode (EnergyManagementMode): The management mode to use. Must be one of:
|
2025-10-28 02:50:31 +01:00
|
|
|
- "OPTIMIZATION": Runs the optimization process.
|
|
|
|
|
- "PREDICTION": Updates the forecast without optimization.
|
2026-03-13 15:48:43 +01:00
|
|
|
- "DISABLED": Does not run.
|
2025-10-28 02:50:31 +01:00
|
|
|
genetic_parameters (GeneticOptimizationParameters, optional): The
|
|
|
|
|
parameter set for the genetic algorithm. If not provided, it will
|
|
|
|
|
be constructed based on the current configuration and predictions.
|
|
|
|
|
genetic_individuals (int, optional): The number of individuals for the
|
|
|
|
|
genetic algorithm. Defaults to the algorithm's internal default (400)
|
|
|
|
|
if not specified.
|
|
|
|
|
genetic_seed (int, optional): The seed for the genetic algorithm. Defaults
|
|
|
|
|
to the algorithm's internal random seed if not specified.
|
|
|
|
|
force_enable (bool, optional): If True, bypasses any disabled state
|
|
|
|
|
to force the update process. This is mostly applicable to
|
|
|
|
|
prediction providers.
|
|
|
|
|
force_update (bool, optional): If True, forces data to be refreshed
|
|
|
|
|
even if a cached version is still valid.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
None
|
2024-12-15 14:40:03 +01:00
|
|
|
"""
|
2025-10-28 02:50:31 +01:00
|
|
|
# Ensure there is only one optimization/ energy management run at a time
|
2026-03-15 13:32:05 +01:00
|
|
|
if not mode in EnergyManagementMode._value2member_map_:
|
2025-10-28 02:50:31 +01:00
|
|
|
raise ValueError(f"Unknown energy management mode {mode}.")
|
2026-03-13 15:48:43 +01:00
|
|
|
if mode == EnergyManagementMode.DISABLED:
|
|
|
|
|
return
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
logger.info("Starting energy management run.")
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
cls._stage = EnergyManagementStage.DATA_ACQUISITION
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Remember/ set the start datetime of this energy management run.
|
|
|
|
|
# None leads
|
|
|
|
|
cls.set_start_datetime(start_datetime)
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Throw away any memory cached results of the last energy management run.
|
|
|
|
|
CacheEnergyManagementStore().clear()
|
2025-06-10 22:00:28 +02:00
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
# Do data aquisition by adapters
|
|
|
|
|
try:
|
|
|
|
|
cls.adapter.update_data(force_enable)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
2026-03-07 14:46:30 +01:00
|
|
|
error_msg = f"Adapter update failed - phase {cls._stage}:\n{e}\n{trace}"
|
2025-12-30 22:08:21 +01:00
|
|
|
logger.error(error_msg)
|
|
|
|
|
|
|
|
|
|
cls._stage = EnergyManagementStage.FORECAST_RETRIEVAL
|
|
|
|
|
|
2026-03-13 15:48:43 +01:00
|
|
|
if mode == EnergyManagementMode.PREDICTION:
|
2025-10-28 02:50:31 +01:00
|
|
|
# Update the predictions
|
|
|
|
|
cls.prediction.update_data(force_enable=force_enable, force_update=force_update)
|
|
|
|
|
logger.info("Energy management run done (predictions updated)")
|
2025-12-30 22:08:21 +01:00
|
|
|
cls._stage = EnergyManagementStage.IDLE
|
2025-10-28 02:50:31 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Prepare optimization parameters
|
|
|
|
|
# This also creates default configurations for missing values and updates the predictions
|
|
|
|
|
logger.info(
|
|
|
|
|
"Starting energy management prediction update and optimzation parameter preparation."
|
|
|
|
|
)
|
|
|
|
|
if genetic_parameters is None:
|
|
|
|
|
genetic_parameters = GeneticOptimizationParameters.prepare()
|
|
|
|
|
|
|
|
|
|
if not genetic_parameters:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Energy management run canceled. Could not prepare optimisation parameters."
|
|
|
|
|
)
|
2025-12-30 22:08:21 +01:00
|
|
|
cls._stage = EnergyManagementStage.IDLE
|
2025-10-28 02:50:31 +01:00
|
|
|
return
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
cls._stage = EnergyManagementStage.OPTIMIZATION
|
|
|
|
|
logger.info("Starting energy management optimization.")
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Take values from config if not given
|
|
|
|
|
if genetic_individuals is None:
|
|
|
|
|
genetic_individuals = cls.config.optimization.genetic.individuals
|
|
|
|
|
if genetic_seed is None:
|
|
|
|
|
genetic_seed = cls.config.optimization.genetic.seed
|
|
|
|
|
|
|
|
|
|
if cls._start_datetime is None: # Make mypy happy - already set by us
|
|
|
|
|
raise RuntimeError("Start datetime not set.")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
optimization = GeneticOptimization(
|
|
|
|
|
verbose=bool(cls.config.server.verbose),
|
|
|
|
|
fixed_seed=genetic_seed,
|
|
|
|
|
)
|
|
|
|
|
solution = optimization.optimierung_ems(
|
|
|
|
|
start_hour=cls._start_datetime.hour,
|
|
|
|
|
parameters=genetic_parameters,
|
|
|
|
|
ngen=genetic_individuals,
|
|
|
|
|
)
|
|
|
|
|
except:
|
|
|
|
|
logger.exception("Energy management optimization failed.")
|
2025-12-30 22:08:21 +01:00
|
|
|
cls._stage = EnergyManagementStage.IDLE
|
2025-10-28 02:50:31 +01:00
|
|
|
return
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
cls._stage = EnergyManagementStage.CONTROL_DISPATCH
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Make genetic solution public
|
|
|
|
|
cls._genetic_solution = solution
|
|
|
|
|
|
|
|
|
|
# Make optimization solution public
|
|
|
|
|
cls._optimization_solution = solution.optimization_solution()
|
|
|
|
|
|
|
|
|
|
# Make plan public
|
|
|
|
|
cls._plan = solution.energy_management_plan()
|
|
|
|
|
|
|
|
|
|
logger.debug("Energy management genetic solution:\n{}", cls._genetic_solution)
|
|
|
|
|
logger.debug("Energy management optimization solution:\n{}", cls._optimization_solution)
|
|
|
|
|
logger.debug("Energy management plan:\n{}", cls._plan)
|
|
|
|
|
logger.info("Energy management run done (optimization updated)")
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
# Do control dispatch by adapters
|
|
|
|
|
try:
|
|
|
|
|
cls.adapter.update_data(force_enable)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
2026-03-07 14:46:30 +01:00
|
|
|
error_msg = f"Adapter update failed - phase {cls._stage}:\n{e}\n{trace}"
|
2025-12-30 22:08:21 +01:00
|
|
|
logger.error(error_msg)
|
|
|
|
|
|
2026-02-22 14:12:42 +01:00
|
|
|
# Remember energy run datetime.
|
|
|
|
|
EnergyManagement._last_run_datetime = to_datetime()
|
|
|
|
|
|
2025-12-30 22:08:21 +01:00
|
|
|
# energy management run finished
|
|
|
|
|
cls._stage = EnergyManagementStage.IDLE
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
async def run(
|
|
|
|
|
self,
|
|
|
|
|
start_datetime: Optional[DateTime] = None,
|
|
|
|
|
mode: Optional[EnergyManagementMode] = None,
|
|
|
|
|
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
|
|
|
|
|
genetic_individuals: Optional[int] = None,
|
|
|
|
|
genetic_seed: Optional[int] = None,
|
|
|
|
|
force_enable: Optional[bool] = False,
|
|
|
|
|
force_update: Optional[bool] = False,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Run the energy management.
|
|
|
|
|
|
|
|
|
|
This method initializes the energy management run by setting its
|
|
|
|
|
start datetime, updating predictions, and optionally starting
|
|
|
|
|
optimization depending on the selected mode or configuration.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
start_datetime (DateTime, optional): The starting timestamp
|
|
|
|
|
of the energy management run. Defaults to the current datetime
|
|
|
|
|
if not provided.
|
|
|
|
|
mode (EnergyManagementMode, optional): The management mode to use. Must be one of:
|
|
|
|
|
- "OPTIMIZATION": Runs the optimization process.
|
|
|
|
|
- "PREDICTION": Updates the forecast without optimization.
|
|
|
|
|
|
|
|
|
|
Defaults to the mode defined in the current configuration.
|
|
|
|
|
genetic_parameters (GeneticOptimizationParameters, optional): The
|
|
|
|
|
parameter set for the genetic algorithm. If not provided, it will
|
|
|
|
|
be constructed based on the current configuration and predictions.
|
|
|
|
|
genetic_individuals (int, optional): The number of individuals for the
|
|
|
|
|
genetic algorithm. Defaults to the algorithm's internal default (400)
|
|
|
|
|
if not specified.
|
|
|
|
|
genetic_seed (int, optional): The seed for the genetic algorithm. Defaults
|
|
|
|
|
to the algorithm's internal random seed if not specified.
|
|
|
|
|
force_enable (bool, optional): If True, bypasses any disabled state
|
|
|
|
|
to force the update process. This is mostly applicable to
|
|
|
|
|
prediction providers.
|
|
|
|
|
force_update (bool, optional): If True, forces data to be refreshed
|
|
|
|
|
even if a cached version is still valid.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
None
|
|
|
|
|
"""
|
|
|
|
|
async with self._run_lock:
|
|
|
|
|
loop = get_running_loop()
|
|
|
|
|
# Create a partial function with parameters "baked in"
|
2026-03-13 15:48:43 +01:00
|
|
|
if start_datetime is None:
|
|
|
|
|
start_datetime = to_datetime()
|
|
|
|
|
if mode is None:
|
|
|
|
|
mode = self.config.ems.mode
|
2025-10-28 02:50:31 +01:00
|
|
|
func = partial(
|
|
|
|
|
EnergyManagement._run,
|
|
|
|
|
start_datetime=start_datetime,
|
|
|
|
|
mode=mode,
|
|
|
|
|
genetic_parameters=genetic_parameters,
|
|
|
|
|
genetic_individuals=genetic_individuals,
|
|
|
|
|
genetic_seed=genetic_seed,
|
|
|
|
|
force_enable=force_enable,
|
|
|
|
|
force_update=force_update,
|
|
|
|
|
)
|
|
|
|
|
# Run optimization in background thread to avoid blocking event loop
|
|
|
|
|
await loop.run_in_executor(executor, func)
|