Files
EOS/src/akkudoktoreos/core/ems.py
T

516 lines
22 KiB
Python
Raw Normal View History

import traceback
2025-10-28 02:50:31 +01:00
from asyncio import Lock, get_running_loop
from concurrent.futures import ThreadPoolExecutor
from enum import StrEnum
2026-07-15 16:38:53 +02:00
from typing import ClassVar, Optional, cast
2024-10-22 10:29:57 +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
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.genetic0.genetic0params import (
Genetic0OptimizationParameters,
)
from akkudoktoreos.optimization.genetic0.genetic0solution import Genetic0Solution
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
2025-10-28 02:50:31 +01:00
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 (
OptimizationAlgorithm,
OptimizationSolution,
)
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
class EnergyManagementStage(StrEnum):
"""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"
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()
class EnergyManagement(
SingletonMixin, ConfigMixin, PredictionMixin, AdapterMixin, PydanticBaseModel
):
2025-10-28 02:50:31 +01:00
"""Energy management."""
# Start datetime.
_start_datetime: ClassVar[Optional[DateTime]] = None
_observation_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
# 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
# Solution of the genetic0 algorithm of latest energy management run with optimization
# For classic API
_genetic0_solution: ClassVar[Optional[Genetic0Solution]] = None
2025-10-28 02:50:31 +01:00
# energy management lock (for energy management run)
_run_lock: ClassVar[Lock] = Lock()
2025-02-12 21:35:51 +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:
return EnergyManagement.set_start_datetime()
2025-02-12 21:35:51 +01:00
return EnergyManagement._start_datetime
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
@property
def observation_datetime(self) -> DateTime:
"""Unrounded run time used to assess the freshness of runtime measurements."""
return EnergyManagement._observation_datetime or self.start_datetime
@classmethod
def set_start_datetime(
cls, start_datetime: Optional[DateTime] = None, interval_seconds: Optional[int] = 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 is rounded down to the selected algorithm slot using elapsed
time, retaining the correct offset during repeated DST hours.
2025-02-12 21:35:51 +01:00
Args:
start_datetime (Optional[DateTime]): The datetime to set as the start.
If None, the current datetime is used.
interval_seconds: Explicit slot duration; otherwise use the configured algorithm.
2025-02-12 21:35:51 +01:00
Returns:
DateTime: The adjusted start datetime.
"""
if start_datetime is None:
start_datetime = to_datetime()
cls._observation_datetime = start_datetime
if interval_seconds is None:
interval_seconds = (
cls.config.optimization.genetic.interval_sec
if cls.config.optimization.algorithm == OptimizationAlgorithm.GENETIC
else 3600
)
if interval_seconds not in (900, 3600):
raise ValueError("Optimization slots must be 900 or 3600 seconds.")
midnight = start_datetime.start_of("day").int_timestamp
elapsed = start_datetime.int_timestamp - midnight
cls._start_datetime = to_datetime(
midnight + (elapsed // interval_seconds) * interval_seconds,
in_timezone=start_datetime.timezone_name,
)
return cls._start_datetime
@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.
2025-10-28 02:50:31 +01:00
Returns:
Optional[EnergyManagementPlan]: The latest energy management plan or None.
"""
return cls._plan
2025-10-28 02:50:31 +01:00
@classmethod
def optimization_solution(cls) -> Optional[OptimizationSolution]:
"""Get the latest optimization solution.
2025-10-28 02:50:31 +01:00
Returns:
Optional[OptimizationSolution]: The latest optimization solution.
"""
return cls._optimization_solution
2025-10-28 02:50:31 +01:00
@classmethod
def genetic_solution(cls) -> Optional[GeneticSolution]:
"""Get the latest solution of the genetic algorithm.
2025-10-28 02:50:31 +01:00
Returns:
Optional[GeneticSolution]: The latest solution of the genetic algorithm.
"""
return cls._genetic_solution
@classmethod
def genetic0_solution(cls) -> Optional[Genetic0Solution]:
"""Get the latest solution of the genetic0 algorithm.
Returns:
Optional[Genetic0Solution]: The latest solution of the genetic algorithm.
"""
return cls._genetic0_solution
2026-07-15 16:38:53 +02:00
async def run(
self,
start_datetime: Optional[DateTime] = None,
mode: Optional[EnergyManagementMode] = None,
algorithm: Optional[OptimizationAlgorithm] = None,
genetic_parameters: Optional[
GeneticOptimizationParameters | ConfigOptimizationRequest
] = None,
genetic_generations: Optional[int] = None,
genetic_individuals: Optional[int] = None,
2025-10-28 02:50:31 +01:00
genetic_seed: Optional[int] = None,
genetic0_parameters: Optional[Genetic0OptimizationParameters] = None,
genetic0_generations: Optional[int] = None,
genetic0_seed: Optional[int] = None,
force_enable: Optional[bool] = False,
force_update: Optional[bool] = False,
) -> Optional[GeneticSolution | Genetic0Solution]:
2025-10-28 02:50:31 +01:00
"""Run the energy management.
2025-10-28 02:50:31 +01:00
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): 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.
- "DISABLED": Does not run.
2025-10-28 02:50:31 +01:00
Defaults to the mode defined in the current configuration.
algorithm (OptimizationAlgorithm, optional):
2026-07-15 16:38:53 +02:00
The algorithm to use. Must be one of:
- "GENETIC": Optimization uses the `GENETIC` optimization algorithm.
- "GENETIC0": Optimization uses the `GENETIC0` optimization algorithm.
2026-07-15 16:38:53 +02:00
Defaults to the algorithm defined in the current configuration.
2025-10-28 02:50:31 +01:00
genetic_parameters (GeneticOptimizationParameters, optional): The
2026-07-15 16:38:53 +02:00
parameter set for the `GENETIC` algorithm. If not provided, it will
2025-10-28 02:50:31 +01:00
be constructed based on the current configuration and predictions.
genetic_generations (int, optional): The number of generations for the
2026-07-15 16:38:53 +02:00
`GENETIC` algorithm. Defaults to the algorithm's internal default (400)
2025-10-28 02:50:31 +01:00
if not specified.
genetic_individuals (int, optional): Population size override for this run.
2026-07-15 16:38:53 +02:00
genetic_seed (int, optional): The seed for the `GENETIC` algorithm. Defaults
2025-10-28 02:50:31 +01:00
to the algorithm's internal random seed if not specified.
genetic0_parameters (Genetic0OptimizationParameters, optional): The
parameter set for the `GENETIC0` algorithm. If not provided, it will
be constructed based on the current configuration and predictions.
genetic0_generations (int, optional): The number of generations for the
`GENETIC0` algorithm. Defaults to the algorithm's internal default (400)
if not specified.
genetic0_seed (int, optional): The seed for the `GENETIC0` algorithm. Defaults
to the algorithm's internal random seed if not specified.
2025-10-28 02:50:31 +01:00
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:
The native optimization solution produced by this run, or None when
no optimization completed. Previous successful results remain available
through the solution getters after an aborted run.
2025-10-28 02:50:31 +01:00
"""
2026-07-15 16:38:53 +02:00
async with EnergyManagement._run_lock:
if mode is None:
mode = self.config.ems.mode
2026-07-15 16:38:53 +02:00
if mode not in EnergyManagementMode._value2member_map_:
raise ValueError(f"Unknown energy management mode {mode}.")
if mode == EnergyManagementMode.DISABLED:
logger.info("Energy management run disabled.")
return None
2026-07-15 16:38:53 +02:00
logger.info("Starting energy management run.")
# --- Data Aquisition ---
EnergyManagement._stage = EnergyManagementStage.DATA_ACQUISITION
# Remember/ set the start datetime of this energy management run.
# None leads to current time as start datetime
if algorithm is None:
algorithm = self.config.optimization.algorithm
if start_datetime is None and algorithm == OptimizationAlgorithm.GENETIC:
# Consumer windows and midnight-based forecasts use the site clock,
# which can differ from the server's local timezone.
start_datetime = to_datetime(in_timezone=self.config.general.timezone)
self.set_start_datetime(
start_datetime,
interval_seconds=(
self.config.optimization.genetic.interval_sec
if algorithm == OptimizationAlgorithm.GENETIC
else 3600
),
)
2026-07-15 16:38:53 +02:00
# Throw away any memory cached results of the last energy management run.
CacheEnergyManagementStore().clear()
# --- Adapter update ---
try:
await self.adapter.update_data(force_enable)
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
error_msg = (
f"Adapter update failed - phase {EnergyManagement._stage}:\n{e}\n{trace}"
)
logger.error(error_msg)
# --- Prediction ---
EnergyManagement._stage = EnergyManagementStage.FORECAST_RETRIEVAL
# Update the predictions
logger.info("Starting energy management prediction update.")
try:
await self.prediction.update_data(
force_enable=force_enable, force_update=force_update
)
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
error_msg = (
f"Prediction update failed - phase {EnergyManagement._stage}:\n{e}\n{trace}"
)
logger.error(error_msg)
2026-07-15 16:38:53 +02:00
if mode == EnergyManagementMode.PREDICTION:
logger.info("Energy management run done (predictions updated)")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
2026-07-15 16:38:53 +02:00
# --- Optimization ---
EnergyManagement._stage = EnergyManagementStage.OPTIMIZATION
optimization_start = to_datetime()
logger.info("Starting energy management optimization.")
if algorithm is None:
algorithm = self.config.optimization.algorithm
run_solution: GeneticSolution | Genetic0Solution
# --- GENETIC algorithm ---
if algorithm == OptimizationAlgorithm.GENETIC:
2026-07-15 16:38:53 +02:00
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
try:
if isinstance(genetic_parameters, ConfigOptimizationRequest):
genetic_parameters = await genetic_parameters.resolve()
elif genetic_parameters is None:
genetic_parameters = await GeneticOptimizationParameters.prepare()
2026-07-15 16:38:53 +02:00
if genetic_parameters is None:
logger.error("GENETIC: Parameter preparation failed; canceling run.")
2026-07-15 16:38:53 +02:00
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
except Exception:
logger.exception("GENETIC: Parameter preparation failed.")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
2026-07-15 16:38:53 +02:00
# Take values from config if not given
if genetic_generations is None:
genetic_generations = self.config.optimization.genetic.generations
2026-07-15 16:38:53 +02:00
if genetic_seed is None:
genetic_seed = self.config.optimization.genetic.seed
if EnergyManagement._start_datetime is None: # Make mypy happy - already set by us
raise RuntimeError(f"{algorithm}: Start datetime not set.")
2026-07-15 16:38:53 +02:00
# --- Optimization (CPU-bound → MUST offload) ---
try:
genetic_optimization = GeneticOptimization(
2026-07-15 16:38:53 +02:00
verbose=bool(self.config.server.verbose),
fixed_seed=genetic_seed,
)
loop = get_running_loop()
start_hour = EnergyManagement._start_datetime.hour
genetic_solution = await loop.run_in_executor(
2026-07-15 16:38:53 +02:00
None,
lambda: genetic_optimization.optimize_ems(
2026-07-15 16:38:53 +02:00
start_hour=start_hour,
parameters=cast(
GeneticOptimizationParameters, genetic_parameters
), # cast for mypy
ngen=genetic_generations,
individuals=genetic_individuals,
2026-07-15 16:38:53 +02:00
),
)
# Build all representations before publishing any of them.
optimization_solution = await genetic_solution.optimization_solution()
plan = genetic_solution.energy_management_plan()
2026-07-15 16:38:53 +02:00
except Exception:
logger.exception(f"{algorithm}: Energy management optimization failed.")
2026-07-15 16:38:53 +02:00
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
2026-07-15 16:38:53 +02:00
# Publish a consistent set without yielding to another coroutine.
EnergyManagement._genetic_solution = genetic_solution
EnergyManagement._optimization_solution = optimization_solution
EnergyManagement._plan = plan
run_solution = genetic_solution
logger.debug(
"{}: Energy management genetic solution:\n{}",
algorithm,
EnergyManagement._genetic_solution,
)
# --- GENETIC0 algorithm ---
elif algorithm == OptimizationAlgorithm.GENETIC0:
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
if genetic0_parameters is None:
genetic0_parameters = await Genetic0OptimizationParameters.prepare()
if genetic0_parameters is None:
logger.error(
f"{algorithm}: Energy management run canceled. "
"Could not prepare optimisation parameters."
)
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
# Take values from config if not given
if genetic0_generations is None:
genetic0_generations = self.config.optimization.genetic0.generations
if genetic0_seed is None:
genetic0_seed = self.config.optimization.genetic0.seed
if EnergyManagement._start_datetime is None: # Make mypy happy - already set by us
raise RuntimeError(f"{algorithm}: Start datetime not set.")
# --- Optimization (CPU-bound → MUST offload) ---
try:
# Imported on first use: a server that runs GENETIC never needs
# the GENETIC0 optimizer and its simulation.
from akkudoktoreos.optimization.genetic0.genetic0 import (
Genetic0Optimization,
)
genetic0_optimization = Genetic0Optimization(
verbose=bool(self.config.server.verbose),
fixed_seed=genetic0_seed,
)
loop = get_running_loop()
start_hour = EnergyManagement._start_datetime.hour
genetic0_solution = await loop.run_in_executor(
None,
lambda: genetic0_optimization.optimize_ems(
start_hour=start_hour,
parameters=cast(
Genetic0OptimizationParameters, genetic0_parameters
), # cast for mypy
ngen=genetic0_generations,
),
)
# Build all representations before publishing any of them.
optimization_solution = await genetic0_solution.optimization_solution()
plan = genetic0_solution.energy_management_plan()
except Exception:
logger.exception(f"{algorithm}: Energy management optimization failed.")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
# Publish a consistent set without yielding to another coroutine.
EnergyManagement._genetic0_solution = genetic0_solution
EnergyManagement._optimization_solution = optimization_solution
EnergyManagement._plan = plan
run_solution = genetic0_solution
logger.debug(
"{}: Energy management genetic solution:\n{}",
algorithm,
EnergyManagement._genetic0_solution,
)
2026-07-15 16:38:53 +02:00
else:
logger.error(f"Unknown optimization algorithm: '{algorithm}'. Skipping.")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
2026-07-15 16:38:53 +02:00
optimization_duration = to_datetime() - optimization_start
logger.info(
"{}: Energy management optimization completed in {:.1f} seconds.",
2026-07-15 16:38:53 +02:00
algorithm,
optimization_duration.total_seconds(),
2025-10-28 02:50:31 +01:00
)
2026-07-15 16:38:53 +02:00
logger.debug(
"{}: Energy management optimization solution:\n{}",
algorithm,
2026-07-15 16:38:53 +02:00
EnergyManagement._optimization_solution,
)
logger.debug("{}: Energy management plan:\n{}", algorithm, EnergyManagement._plan)
2026-07-15 16:38:53 +02:00
logger.info("{}: Energy management run done (optimization updated)", algorithm)
2026-07-15 16:38:53 +02:00
# --- Dispatch control by adapters ---
EnergyManagement._stage = EnergyManagementStage.CONTROL_DISPATCH
# Dispatch (sync → optionally offload)
try:
await self.adapter.update_data(force_enable)
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
error_msg = (
f"Adapter update failed - phase {EnergyManagement._stage}:\n{e}\n{trace}"
)
logger.error(error_msg)
# --- Idle ---
# Remember energy run datetime.
EnergyManagement._last_run_datetime = to_datetime()
# energy management run finished
EnergyManagement._stage = EnergyManagementStage.IDLE
return run_solution