mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
* 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>
570 lines
24 KiB
Python
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
|