2026-10-11 10:48:04 +02:00
|
|
|
import json
|
|
|
|
|
import threading
|
2025-03-27 21:53:01 +01:00
|
|
|
import traceback
|
2026-10-11 10:48:04 +02:00
|
|
|
import urllib.request
|
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
|
2026-07-15 16:38:53 +02:00
|
|
|
from typing import ClassVar, Optional, cast
|
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
|
2026-07-29 12:56:08 +02:00
|
|
|
from akkudoktoreos.optimization.genetic0.genetic0params import (
|
|
|
|
|
Genetic0OptimizationParameters,
|
|
|
|
|
)
|
|
|
|
|
from akkudoktoreos.optimization.genetic0.genetic0solution import Genetic0Solution
|
2026-09-17 20:14:24 +02:00
|
|
|
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
|
2026-08-07 13:13:17 +02:00
|
|
|
from akkudoktoreos.optimization.optimization import (
|
|
|
|
|
OptimizationAlgorithm,
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-10-11 10:48:04 +02:00
|
|
|
NOTIFY_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
_notify_failing = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _post_notification(url: str, event: dict) -> None:
|
|
|
|
|
"""POST one event; log a failure once per failure streak, not every run."""
|
|
|
|
|
global _notify_failing
|
|
|
|
|
# The scheme is restricted to http(s) by the notify_url setting.
|
|
|
|
|
request = urllib.request.Request( # noqa: S310
|
|
|
|
|
url,
|
|
|
|
|
data=json.dumps(event).encode("utf-8"),
|
|
|
|
|
headers={"Content-Type": "application/json"},
|
|
|
|
|
method="POST",
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(request, timeout=NOTIFY_TIMEOUT_SECONDS) as response: # noqa: S310
|
|
|
|
|
response.read()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
if not _notify_failing:
|
|
|
|
|
logger.warning("Optimization notification to {} failed: {}", url, exc)
|
|
|
|
|
_notify_failing = True
|
|
|
|
|
return
|
|
|
|
|
if _notify_failing:
|
|
|
|
|
logger.info("Optimization notification to {} works again.", url)
|
|
|
|
|
_notify_failing = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def notify_optimization_completed(url: Optional[str], event: dict) -> Optional[threading.Thread]:
|
|
|
|
|
"""Tell ``ems.notify_url`` that a new solution is available.
|
|
|
|
|
|
|
|
|
|
Runs in a background thread so a slow or unreachable receiver never delays
|
|
|
|
|
the energy management run.
|
|
|
|
|
"""
|
|
|
|
|
if not url:
|
|
|
|
|
return None
|
|
|
|
|
thread = threading.Thread(
|
|
|
|
|
target=_post_notification, args=(url, event), name="eos-notify", daemon=True
|
|
|
|
|
)
|
|
|
|
|
thread.start()
|
|
|
|
|
return thread
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-17 20:14:24 +02:00
|
|
|
_observation_datetime: ClassVar[Optional[DateTime]] = None
|
2024-12-15 14:40:03 +01:00
|
|
|
|
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
|
|
|
|
|
|
2026-07-29 12:56:08 +02:00
|
|
|
# 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
|
|
|
|
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:
|
2026-09-10 23:20:35 +02:00
|
|
|
return EnergyManagement.set_start_datetime()
|
2025-02-12 21:35:51 +01:00
|
|
|
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
|
|
|
|
|
|
2026-09-17 20:14:24 +02:00
|
|
|
@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
|
|
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
@classmethod
|
2026-09-17 20:14:24 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-09-17 20:14:24 +02:00
|
|
|
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.
|
2026-09-17 20:14:24 +02:00
|
|
|
interval_seconds: Explicit slot duration; otherwise use the configured algorithm.
|
2025-02-12 21:35:51 +01:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
DateTime: The adjusted start datetime.
|
|
|
|
|
"""
|
2024-12-15 14:40:03 +01:00
|
|
|
if start_datetime is None:
|
|
|
|
|
start_datetime = to_datetime()
|
2026-09-17 20:14:24 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2024-12-15 14:40:03 +01:00
|
|
|
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
|
|
|
|
2026-07-29 12:56:08 +02:00
|
|
|
@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,
|
2026-08-07 13:13:17 +02:00
|
|
|
algorithm: Optional[OptimizationAlgorithm] = None,
|
2026-09-17 20:14:24 +02:00
|
|
|
genetic_parameters: Optional[
|
|
|
|
|
GeneticOptimizationParameters | ConfigOptimizationRequest
|
|
|
|
|
] = None,
|
2026-07-29 12:56:08 +02:00
|
|
|
genetic_generations: Optional[int] = None,
|
2026-09-17 20:14:24 +02:00
|
|
|
genetic_individuals: Optional[int] = None,
|
2025-10-28 02:50:31 +01:00
|
|
|
genetic_seed: Optional[int] = None,
|
2026-07-29 12:56:08 +02:00
|
|
|
genetic0_parameters: Optional[Genetic0OptimizationParameters] = None,
|
|
|
|
|
genetic0_generations: Optional[int] = None,
|
|
|
|
|
genetic0_seed: Optional[int] = None,
|
2024-12-15 14:40:03 +01:00
|
|
|
force_enable: Optional[bool] = False,
|
|
|
|
|
force_update: Optional[bool] = False,
|
2026-09-16 18:20:49 +02:00
|
|
|
) -> Optional[GeneticSolution | Genetic0Solution]:
|
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
|
|
|
|
|
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
|
|
|
|
|
|
|
|
Defaults to the mode defined in the current configuration.
|
2026-08-07 13:13:17 +02:00
|
|
|
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.
|
2026-07-29 12:56:08 +02:00
|
|
|
- "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.
|
2026-07-29 12:56:08 +02:00
|
|
|
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.
|
2026-09-17 20:14:24 +02:00
|
|
|
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.
|
2026-07-29 12:56:08 +02:00
|
|
|
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:
|
2026-09-16 18:20:49 +02:00
|
|
|
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:
|
2026-03-13 15:48:43 +01:00
|
|
|
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.")
|
2026-09-16 18:20:49 +02:00
|
|
|
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
|
2026-09-17 20:14:24 +02:00
|
|
|
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.")
|
2026-08-08 11:37:50 +02:00
|
|
|
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
|
2026-09-16 18:20:49 +02:00
|
|
|
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
|
|
|
|
|
|
2026-09-16 18:20:49 +02:00
|
|
|
run_solution: GeneticSolution | Genetic0Solution
|
|
|
|
|
|
2026-07-29 12:56:08 +02:00
|
|
|
# --- GENETIC algorithm ---
|
2026-08-07 13:13:17 +02:00
|
|
|
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
|
2026-07-29 12:56:08 +02:00
|
|
|
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
|
2026-09-17 20:14:24 +02:00
|
|
|
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:
|
2026-09-17 20:14:24 +02:00
|
|
|
logger.error("GENETIC: Parameter preparation failed; canceling run.")
|
2026-07-15 16:38:53 +02:00
|
|
|
EnergyManagement._stage = EnergyManagementStage.IDLE
|
2026-09-16 18:20:49 +02:00
|
|
|
return None
|
2026-09-17 20:14:24 +02:00
|
|
|
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
|
2026-07-29 12:56:08 +02:00
|
|
|
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
|
2026-07-29 12:56:08 +02:00
|
|
|
raise RuntimeError(f"{algorithm}: Start datetime not set.")
|
2026-07-15 16:38:53 +02:00
|
|
|
|
|
|
|
|
# --- Optimization (CPU-bound → MUST offload) ---
|
|
|
|
|
try:
|
2026-07-29 12:56:08 +02:00
|
|
|
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
|
2026-07-29 12:56:08 +02:00
|
|
|
genetic_solution = await loop.run_in_executor(
|
2026-07-15 16:38:53 +02:00
|
|
|
None,
|
2026-07-29 12:56:08 +02:00
|
|
|
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
|
2026-07-29 12:56:08 +02:00
|
|
|
ngen=genetic_generations,
|
2026-09-17 20:14:24 +02:00
|
|
|
individuals=genetic_individuals,
|
2026-07-15 16:38:53 +02:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-16 18:20:49 +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:
|
2026-07-29 12:56:08 +02:00
|
|
|
logger.exception(f"{algorithm}: Energy management optimization failed.")
|
2026-07-15 16:38:53 +02:00
|
|
|
EnergyManagement._stage = EnergyManagementStage.IDLE
|
2026-09-16 18:20:49 +02:00
|
|
|
return None
|
2026-07-15 16:38:53 +02:00
|
|
|
|
2026-09-16 18:20:49 +02:00
|
|
|
# Publish a consistent set without yielding to another coroutine.
|
2026-07-29 12:56:08 +02:00
|
|
|
EnergyManagement._genetic_solution = genetic_solution
|
2026-09-16 18:20:49 +02:00
|
|
|
EnergyManagement._optimization_solution = optimization_solution
|
|
|
|
|
EnergyManagement._plan = plan
|
|
|
|
|
run_solution = genetic_solution
|
2026-07-29 12:56:08 +02:00
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
"{}: Energy management genetic solution:\n{}",
|
|
|
|
|
algorithm,
|
|
|
|
|
EnergyManagement._genetic_solution,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# --- GENETIC0 algorithm ---
|
2026-08-07 13:13:17 +02:00
|
|
|
elif algorithm == OptimizationAlgorithm.GENETIC0:
|
2026-07-29 12:56:08 +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.")
|
|
|
|
|
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
|
2026-09-16 18:20:49 +02:00
|
|
|
return None
|
2026-07-29 12:56:08 +02:00
|
|
|
|
|
|
|
|
# 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:
|
2026-10-11 08:40:43 +02:00
|
|
|
# Imported on first use: a server that runs GENETIC never needs
|
|
|
|
|
# the GENETIC0 optimizer and its simulation.
|
|
|
|
|
from akkudoktoreos.optimization.genetic0.genetic0 import (
|
|
|
|
|
Genetic0Optimization,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-29 12:56:08 +02:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-16 18:20:49 +02:00
|
|
|
# Build all representations before publishing any of them.
|
|
|
|
|
optimization_solution = await genetic0_solution.optimization_solution()
|
|
|
|
|
plan = genetic0_solution.energy_management_plan()
|
|
|
|
|
|
2026-07-29 12:56:08 +02:00
|
|
|
except Exception:
|
|
|
|
|
logger.exception(f"{algorithm}: Energy management optimization failed.")
|
|
|
|
|
EnergyManagement._stage = EnergyManagementStage.IDLE
|
2026-09-16 18:20:49 +02:00
|
|
|
return None
|
2026-07-29 12:56:08 +02:00
|
|
|
|
2026-09-16 18:20:49 +02:00
|
|
|
# Publish a consistent set without yielding to another coroutine.
|
2026-07-29 12:56:08 +02:00
|
|
|
EnergyManagement._genetic0_solution = genetic0_solution
|
2026-09-16 18:20:49 +02:00
|
|
|
EnergyManagement._optimization_solution = optimization_solution
|
|
|
|
|
EnergyManagement._plan = plan
|
|
|
|
|
run_solution = genetic0_solution
|
2026-07-29 12:56:08 +02:00
|
|
|
|
|
|
|
|
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
|
2026-09-16 18:20:49 +02:00
|
|
|
return None
|
2026-07-15 16:38:53 +02:00
|
|
|
|
|
|
|
|
optimization_duration = to_datetime() - optimization_start
|
|
|
|
|
logger.info(
|
2026-07-29 12:56:08 +02:00
|
|
|
"{}: 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(
|
2026-07-29 12:56:08 +02:00
|
|
|
"{}: Energy management optimization solution:\n{}",
|
|
|
|
|
algorithm,
|
2026-07-15 16:38:53 +02:00
|
|
|
EnergyManagement._optimization_solution,
|
|
|
|
|
)
|
2026-07-29 12:56:08 +02:00
|
|
|
logger.debug("{}: Energy management plan:\n{}", algorithm, EnergyManagement._plan)
|
2026-07-15 16:38:53 +02:00
|
|
|
|
2026-07-29 12:56:08 +02:00
|
|
|
logger.info("{}: Energy management run done (optimization updated)", algorithm)
|
2026-10-11 10:48:04 +02:00
|
|
|
notify_optimization_completed(
|
|
|
|
|
getattr(self.config.ems, "notify_url", None),
|
|
|
|
|
{
|
|
|
|
|
"event": "optimization_completed",
|
|
|
|
|
"algorithm": str(algorithm),
|
|
|
|
|
"completed_at": to_datetime().to_iso8601_string(),
|
|
|
|
|
"duration_s": round(optimization_duration.total_seconds(), 1),
|
|
|
|
|
},
|
|
|
|
|
)
|
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
|
2026-09-16 18:20:49 +02:00
|
|
|
return run_solution
|