Files
EOS/src/akkudoktoreos/core/ems.py
T
70b72bfbce feat(ems): notify a URL after every completed optimization (#1383)
* feat(devices): add load-dependent DC-to-AC efficiency curve for GENETIC inverters

Real inverters are much less efficient at low load than at rated power
(e.g. 0.80 at 2 % vs. 0.93 at 7 % load). A constant dc_to_ac_efficiency
overrates night-time battery discharge into a small base load and, with
it, arbitrage and grid charging.

Add two optional inverter settings for the GENETIC optimizer:

- dc_to_ac_efficiency_curve: list of (load_fraction, efficiency) points,
  load fraction = AC power / max_power_w. Points must be strictly
  increasing in [0, 1] with efficiencies in (0, 1]; at least two points.
  Linear interpolation, clamped at both ends. When set, the curve
  replaces dc_to_ac_efficiency.
- dc_to_ac_efficiency_reference_load_fraction (default 0.06): load at
  which the curve values stored energy without a specific conversion.

Battery discharges in Inverter.process_energy() use the efficiency at
the AC energy of each conversion. The end-of-horizon value, the terminal
and tail value curves and the AC charge break-even penalty use
Inverter.reference_dc_to_ac_efficiency.

Without a curve, dc_to_ac_efficiency_at() and the reference efficiency
return dc_to_ac_efficiency itself, so results are bit-identical to the
previous model. GENETIC0 is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(genetic): make the fitness cache size configurable

The per-run fitness cache of the GENETIC optimizer is unbounded. It
grows with individuals x generations (~110k keys at 300 x 400, about
1.1 kB each, 1.6 kB with an EV) and adds 120-170 MB to the peak memory
of a run, while only 6-15 % of the lookups hit. On 512 MB boards this
is the largest part of the optimizer's memory.

Add optimization.genetic.fitness_cache_max_entries:

- None (default): unbounded, the previous behaviour.
- 0: no cache; every genome is evaluated.
- N > 0: at most N keys. The cache is then an OrderedDict and evicts
  the oldest key first (FIFO, O(1)); LRU would cost bookkeeping on
  every hit for a cache that rarely hits.

A cache hit returns exactly what the deterministic evaluation would
return, so the plan for a fixed seed is identical with any setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf: import heavy optional dependencies on first use

A GENETIC server with external (import) providers and EOSdash disabled
loads several large packages at start that it never uses, which costs
a noticeable part of the memory on 512 MB boards:

- matplotlib via the GENETIC/GENETIC0 PDF report modules (server/eos.py)
- statsmodels via the Holt-Winters fallback in prediction/priceabc.py
- pvlib via the weather base class and the pvlib-based providers
- bs4/lxml via the ClearOutside provider
- scipy via the self-consumption interpolator singletons, which were
  loaded at import time
- the GENETIC0 optimizer, imported by core/ems.py
- tzfpy, whose polygon data the computed general.timezone loads

Import these where they are used. The interpolator tables are loaded
on first use (thread-safe). Nothing changes in what is computed.

general.timezone is computed from latitude/longitude and cannot be set.
Add general.timezone_override (IANA name, validated with pendulum):
when set, general.timezone returns it and the lookup is skipped. When
unset (default) the lookup is unchanged.

Tests that patched the moved names now patch them at their source.
A new test starts the server module in a subprocess and checks that
none of these modules is loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(prediction): evaluate self-consumption tables without SciPy

The GENETIC inverter (and the GENETIC0 one) evaluates a pickled
scipy.interpolate.RegularGridInterpolator for the self-consumption
probabilities. SciPy is used nowhere else in EOS, yet loading it costs
about 35 MB RSS (21 MB anonymous) on Linux arm64, which every
optimization run pays.

Add utils/gridinterpolator.py:

- LinearGridInterpolator: 2-D linear interpolation with the same steps
  as SciPy's linear 2-D path (interval search, normalised distances,
  corner weights summed in the same order, fill value outside the grid,
  NaN for NaN input).
- load_grid_interpolator(): reads the shipped pickles with a restricted
  unpickler that maps the SciPy class to a stand-in and accepts only
  the NumPy globals needed for the arrays. SciPy is not imported, and
  the data files no longer depend on SciPy's private module paths.

Results are bit-identical to SciPy on Linux (aarch64, checked on 3.6M
points per table including the query pattern of the inverter). The
macOS arm64 SciPy wheel fuses multiply-add, so there SciPy itself
differs by <= 1.1e-16; GENETIC plans for fixed seeds are unchanged on
both platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(prediction): make the SciPy-free table evaluation opt-in

Add optimization.self_consumption_interpolator (scipy | numpy, default scipy).
The default keeps evaluating the self-consumption tables with SciPy's
RegularGridInterpolator, built from the same grid, values and fill value the
pickle holds, so results are unchanged. With numpy the tables are evaluated by
LinearGridInterpolator and SciPy is never imported. The backend is read on
every evaluation, so a config change applies to the next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(genetic): evaluate candidates in parallel worker processes

A GENETIC run evaluated every candidate in the server process, so a run
used one CPU core however many the machine had. On small ARM boards
(4x Cortex-A55, thermally limited) a 300x200 run took 12-15 minutes.

Candidates are now evaluated in up to two worker processes, forked once
per run after the run is fully prepared. Each worker owns a copy of the
prepared optimizer; only genomes and results cross the process boundary.
The fitness cache stays in the server process: hits are answered there,
and identical genomes in a batch are evaluated once, as a serial run
would hit the cache for the repeats. Results are applied only after the
whole batch returned. If a batch fails or times out, the run continues
serially. For a fixed seed the result is identical to a serial run.

optimization.genetic.workers sets the number of processes. Unset
(default) is automatic: one CPU core always stays free and at most two
are used (1-2 cores -> 1, 3 or more -> 2), capped by a cgroup v2 CPU
limit of the process or any parent (container limit, systemd CPUQuota).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWR1HVM8a8src23xuyL3T

* feat(ems): notify a URL after every completed optimization

A client that consumes EOS plans (here DVhub) had to poll
/v1/energy-management/optimization/solution to notice a new plan. That
delays the plan by up to the poll interval and makes EOS serialize the
complete solution on every poll, also while it is computing.

ems.notify_url (default None = off) receives an HTTP POST with a small
JSON event ({"event": "optimization_completed", "algorithm",
"completed_at", "duration_s"}) when a run has published its solution.
The POST runs in a daemon thread with a 5 s timeout, so a slow or
unreachable receiver never delays energy management; a failure is
logged once per failure streak. Only http(s) URLs are accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWR1HVM8a8src23xuyL3T

* fix(genetic): freeze the optimization configuration for the duration of a run

The genetic optimization reads horizon_hours, tail_horizon_hours,
interval_sec and prediction.hours from the live configuration on every
access (control_slots, tail_slots, ... are properties). A client that
updates the configuration while a run is in progress - DVhub shrinks or
grows the control horizon as the known price horizon moves during the
night - changed these values mid-run. Genomes and device arrays sized at
the start then no longer matched the simulation loop and the final
evaluation failed:

    IndexError: index 84 is out of bounds for axis 0 with size 84

Long runs on slow hardware hit this almost every night.

optimize_ems() now takes a deep copy of the genetic configuration and the
prediction hours at the start of a run, all reads in genetic.py go through
it, and it is released when the run ends (also on errors). New values apply
to the next run.

Tests: tests/test_genetic_config_frozen_per_run.py reproduces the error
without the fix (horizon 20 -> 24 mid-run: index 20 out of bounds) and
passes with it; genetic/optimization tests 439 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(genetic): pin evaluation workers, keep the server responsive

Workers are pinned to the last `workers` CPU cores (new setting
optimization.genetic.pin_workers, default on); the server process stays
unpinned. The run never spreads beyond `workers` cores without a CPU
quota, and the server and the rest of the system keep the other cores.
No pinning without a spare core.

With a CPU limit (container cpu.max, systemd CPUQuota) the automatic
worker count now keeps a quarter core of it for the server process
(limit 2 -> 1 worker, 2.5 -> 2): two workers used a 2-core limit fully.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWR1HVM8a8src23xuyL3T

* fix(ems): tolerate settings without notify_url

Test doubles and older configuration objects have no ems.notify_url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWR1HVM8a8src23xuyL3T

* fix(devices): evaluate the efficiency curve at the delivered AC load

When the battery limits a discharge (low state of charge or its power
limit), the conversion ran with the efficiency of the requested AC energy.
With a 1000 Wh battery at 10 % SoC, a 1000 W inverter and the curve
[(0, .8), (.5, .95), (1, .95)], a request of 500 Wh AC returned 95 Wh AC
from the available 100 Wh DC, although the curve gives 82.5 % at that load.

The delivered AC energy now solves a = dc * efficiency(a / rated) exactly on
the piecewise-linear curve (solve_ac_for_dc_energy), bounded by the request.
Unlimited discharges and inverters without a curve are unchanged.

Regression tests use a real Battery: low SoC and a power-limited discharge,
each with 60- and 15-minute slots, battery discharge losses, the load path
through process_energy, and constant efficiency.

* docs: regenerate OpenAPI for the efficiency-curve fix

* docs: regenerate OpenAPI after merging the efficiency-curve fix

* docs: regenerate OpenAPI after merging the efficiency-curve fix

* docs: regenerate OpenAPI after merging the efficiency-curve fix

* test(genetic): cover serial fallback on platforms without fork

---------

Co-authored-by: Christin <christin@BigBrother.bikini.bottom.zone>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Andreas <drbacke@gmx.de>
2026-10-11 10:48:04 +02:00

570 lines
24 KiB
Python

import json
import threading
import traceback
import urllib.request
from asyncio import Lock, get_running_loop
from concurrent.futures import ThreadPoolExecutor
from enum import StrEnum
from typing import ClassVar, Optional, cast
from loguru import logger
from pydantic import computed_field
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import (
AdapterMixin,
ConfigMixin,
PredictionMixin,
SingletonMixin,
)
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
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
# The executor to execute the CPU heavy energy management run
executor = ThreadPoolExecutor(max_workers=1)
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()
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
class EnergyManagement(
SingletonMixin, ConfigMixin, PredictionMixin, AdapterMixin, PydanticBaseModel
):
"""Energy management."""
# Start datetime.
_start_datetime: ClassVar[Optional[DateTime]] = None
_observation_datetime: ClassVar[Optional[DateTime]] = None
# last run datetime. Used by energy management task
_last_run_datetime: ClassVar[Optional[DateTime]] = None
# Current energy management stage
_stage: ClassVar[EnergyManagementStage] = EnergyManagementStage.IDLE
# 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
# energy management lock (for energy management run)
_run_lock: ClassVar[Lock] = Lock()
@computed_field # type: ignore[prop-decorator]
@property
def start_datetime(self) -> DateTime:
"""The starting datetime of the current or latest energy management."""
if EnergyManagement._start_datetime is None:
return EnergyManagement.set_start_datetime()
return EnergyManagement._start_datetime
@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:
"""Set the start datetime for the next energy management run.
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.
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.
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
@classmethod
def plan(cls) -> Optional[EnergyManagementPlan]:
"""Get the latest energy management plan.
Returns:
Optional[EnergyManagementPlan]: The latest energy management plan or None.
"""
return cls._plan
@classmethod
def optimization_solution(cls) -> Optional[OptimizationSolution]:
"""Get the latest optimization solution.
Returns:
Optional[OptimizationSolution]: The latest optimization solution.
"""
return cls._optimization_solution
@classmethod
def genetic_solution(cls) -> Optional[GeneticSolution]:
"""Get the latest solution of the genetic algorithm.
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
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,
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]:
"""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): The starting timestamp of the energy management run.
mode (EnergyManagementMode): The management mode to use. Must be one of:
- "OPTIMIZATION": Runs the optimization process.
- "PREDICTION": Updates the forecast without optimization.
- "DISABLED": Does not run.
Defaults to the mode defined in the current configuration.
algorithm (OptimizationAlgorithm, optional):
The algorithm to use. Must be one of:
- "GENETIC": Optimization uses the `GENETIC` optimization algorithm.
- "GENETIC0": Optimization uses the `GENETIC0` optimization algorithm.
Defaults to the algorithm 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_generations (int, optional): The number of generations for the
`GENETIC` algorithm. Defaults to the algorithm's internal default (400)
if not specified.
genetic_individuals (int, optional): Population size override for this run.
genetic_seed (int, optional): The seed for the `GENETIC` algorithm. Defaults
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.
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.
"""
async with EnergyManagement._run_lock:
if mode is None:
mode = self.config.ems.mode
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
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
),
)
# 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)
if mode == EnergyManagementMode.PREDICTION:
logger.info("Energy management run done (predictions updated)")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
# --- 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:
# 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()
if genetic_parameters is None:
logger.error("GENETIC: Parameter preparation failed; canceling run.")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
except Exception:
logger.exception("GENETIC: Parameter preparation failed.")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
# Take values from config if not given
if genetic_generations is None:
genetic_generations = self.config.optimization.genetic.generations
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.")
# --- Optimization (CPU-bound → MUST offload) ---
try:
genetic_optimization = GeneticOptimization(
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(
None,
lambda: genetic_optimization.optimize_ems(
start_hour=start_hour,
parameters=cast(
GeneticOptimizationParameters, genetic_parameters
), # cast for mypy
ngen=genetic_generations,
individuals=genetic_individuals,
),
)
# Build all representations before publishing any of them.
optimization_solution = await genetic_solution.optimization_solution()
plan = genetic_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._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,
)
else:
logger.error(f"Unknown optimization algorithm: '{algorithm}'. Skipping.")
EnergyManagement._stage = EnergyManagementStage.IDLE
return None
optimization_duration = to_datetime() - optimization_start
logger.info(
"{}: Energy management optimization completed in {:.1f} seconds.",
algorithm,
optimization_duration.total_seconds(),
)
logger.debug(
"{}: Energy management optimization solution:\n{}",
algorithm,
EnergyManagement._optimization_solution,
)
logger.debug("{}: Energy management plan:\n{}", algorithm, EnergyManagement._plan)
logger.info("{}: Energy management run done (optimization updated)", algorithm)
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),
},
)
# --- 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