feat: deliver complete GENETIC optimization to main (#1330)

* feat: adapt configuration for multi optimization algorithms

Decouple configuration from optimization algorithm parameters. Add to_[algorithm]_param() methods
to the configuration that derive optimization algorithm specific parameters from the configuration.
Add x-scope tags to the configuration options that describe for which specific algorithms the
configuration option is for.

The whole device settings are restructured. There are now general settings for the device classes
with the afore mentioned to_[algorithm]_param() methods. The general device settings got their own
directory `devices/settings`. By this the parameter class also does not have to be a pydantic model
which can be used for future optimization/ simulations speed up.

Also the parameter class for a device is now part of the device module. This better decouples and
also is the natural place for parameters of a device.

Besides this feature there are also fixes and improvements:

* feat: extend home appliance time window settings and simulation

  Home appliance can now be configured for multiple runs with per-cycle allowed time windows. The
  number of remaining cycles to plan is determined at runtime by reading the
  ``cycles_completed_measurement_key`` from the measurement store.

* feat: specialiced CycleTimeWindowSequence for time window sequences

  Sequence of time windows associated to cycles.

  This model specializes ``ValueTimeWindowSequence`` so that the ``value``
  field of each ``ValueTimeWindow`` encodes the **cycle index** (0-based
  integer) the window belongs to.

  Typical use: an appliance that must run ``n`` times per day, each run
  constrained to a distinct time window.  Assign ``value=0`` to windows
  for the first cycle, ``value=1`` for the second, and so on.  Multiple
  windows may share the same cycle index (their allowed regions are unioned).
  Windows with ``value=None`` are silently ignored by all cycle-aware methods.

* fix: Make test_configmigrate also regard the _ANY_SENTENIEL in key values

* chore: Make devices configurations a map instead of a list

  This makes config paths stable regardless of declaration order and lets each device settings
  class build its own config path from ``self.device_id`` without needing an external index.
  Tests are adapted likewise.

  Devices configurations are automatically migrated from lists to maps.

* chore: rename levelized_cost_of_storage_kwh to levelized_cost_of_storage_amt kwh

  This better fits in the naming scheme and also makes clear the costs are money.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>

* fix: runtime config update ignored by config file

Runtime settings were handed back to pydantic-settings as init settings,
which rank below the config file and the environment. Any key already
present in EOS.config.json or in the environment silently discarded the
update, so a bulk PUT /v1/config returned 200 without applying anything,
while the granular PUT /v1/config/{path} endpoint kept working.

Add a dedicated runtime settings source ranked directly below the command
line arguments and record granular updates there as well, so both
endpoints share one store that survives re-evaluation of the settings
sources. Environment variables keep precedence over the config file for
all keys that were not set at runtime.

Also repairs revert_settings() and update(), which passed their data
through the same init settings.

Closes #1303

* fix: env vars ignored on first config build

ConfigEOS.__init__ passed self as first positional argument to _setup,
which forwards it to pydantic_settings.BaseSettings.__init__. Its first
positional parameter is _case_sensitive, so the environment source
matched the upper case variable names against the lower case field names
and returned nothing. Environment settings only took effect after the
next configuration setup.

* docs: changelog for config priority fixes

* fix(config): preserve device identities and storage costs during migration

* fix(measurement): restore JSON records into the existing singleton

* fix(devices): preserve charge-rate typing and public import compatibility

* feat(measurement): integrate typed energy quality and capacity APIs

Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included.

Co-authored-by: Andreas <drbacke@gmx.de>

* docs(measurement): describe household settings and consolidate regression coverage

* docs(measurement): regenerate configuration and API contracts

* test(measurement): isolate capacity database state between tests

* ruff format fix

* fix(measurement): restore JSON records into the existing singleton

* test(measurement): assert restored timestamps before timezone conversion

* test(measurement): assert restored timestamps before timezone conversion

* fix: preserve imported feed-in revenue during parameter preparation

Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged.

Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix.

Co-authored-by: Christin <info@bikinibottom.capital>

Co-authored-by: Normann <github@koldrack.com>

* feat(devices): port slot-aware battery export and direct-use physics

Port scoped device changes from d2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results.

Co-authored-by: Andreas <drbacke@gmx.de>

Co-authored-by: Christin <info@bikinibottom.capital>

* docs(measurement): align API version with refreshed prerequisites

* fix: return only completed optimization results per run

* feat(pvforecast): add calibrated local Akkudoktor backend

Port local PV modeling and outage calibration from feature commits f976335, 6dc58c3 and faed0fd by Andreas. Keep PVForecastAkkudoktor identity and remote default, adapt to async storage, and migrate legacy provider settings.

* fix(cache): distinguish callables in the shared EMS cache

Include the function object in cache keys so methods of one interpolator cannot reuse a probability as a power value. Cover both call orders, keyword arguments, cache hits and separate closures with identical qualified names.

* fix(devices): constrain the physics port and validate export levels

Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing.

* docs(pvforecast): regenerate local backend configuration schema

* docs(devices): regenerate slot-physics configuration and OpenAPI schemas

* test: type dynamic Optimize regression arguments

* style: wrap imported tariff test parameter import

* style(pvforecast): apply CI import formatting

* docs(pvforecast): refresh API version after CI formatting

* fix(config): satisfy typed device conversion and migration contracts

* docs(config): refresh validated configuration prerequisite schemas

* fix(measurement): enforce typed capacity and sample validation

* test(devices): align physics regressions with strict type checking

* style(measurement): normalize imports for CI

* docs(measurement): refresh typed measurement API schemas

* docs(devices): refresh API version after prerequisite merge

* test: make optimization dispatch timezones explicit

* docs(interpolator): use portable reStructuredText markup

* docs(devices): refresh API version after docstring compatibility fix

* feat: complete configuration-driven GENETIC optimization and reports (#1329)

* feat(devices): port slot-aware battery export and direct-use physics

Port scoped device changes from d2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results.

Co-authored-by: Andreas <drbacke@gmx.de>

Co-authored-by: Christin <info@bikinibottom.capital>

* feat(optimization): port tested terminal and tail value primitives

Source d2e2d58237. 22 primitive tests pass; integration with the optimizer, forecast horizon and API is still pending.

Co-authored-by: Andreas <drbacke@gmx.de>

Co-authored-by: Christin <info@bikinibottom.capital>

* fix(devices): preserve charge-rate typing and public import compatibility

* feat(measurement): integrate typed energy quality and capacity APIs

Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included.

Co-authored-by: Andreas <drbacke@gmx.de>

* test(integration): validate optimizer economics and document measurement settings

* docs(integration): record tested checkpoint and remaining consolidation work

* docs(development): define isolated PR packages and remaining porting gates

* docs(integration): refresh API version after measurement reconciliation

* docs(integration): record PR readiness verification results

* docs(development): record publication and verification of PR 1322

* test(measurement): assert restored timestamps before timezone conversion

* docs(development): record corrected PR head and CI progress

* docs(integration): refresh API version after prerequisite alignment

* docs(integration): define parallel packages and Optimize compatibility gates

* fix: preserve imported feed-in revenue during parameter preparation

Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged.

Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix.

Co-authored-by: Christin <info@bikinibottom.capital>

Co-authored-by: Normann <github@koldrack.com>

* test(integration): verify tariff protection with mapped device physics

* fix: return only completed optimization results per run

* test(integration): verify algorithm aliases and mapped-device contracts

* fix(cache): distinguish callables in the shared EMS cache

Include the function object in cache keys so methods of one interpolator cannot reuse a probability as a power value. Cover both call orders, keyword arguments, cache hits and separate closures with identical qualified names.

* fix(devices): constrain the physics port and validate export levels

Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing.

* feat(pvforecast): add calibrated local Akkudoktor backend

Port local PV modeling and outage calibration from feature commits f976335, 6dc58c3 and faed0fd by Andreas. Keep PVForecastAkkudoktor identity and remote default, adapt to async storage, and migrate legacy provider settings.

* docs(integration): record combined compatibility checks and green JSON PR CI

* test: type dynamic Optimize regression arguments

* docs(integration): record Optimize fix PR publication

* docs(integration): record imported tariff protection PR

* style(pvforecast): apply CI import formatting

* style(integration): align combined regression imports

* test: make optimization dispatch timezones explicit

* docs(interpolator): use portable reStructuredText markup

* chore: validate combined integration with locked mypy

* docs: hand off six validated pull requests for manual review

* feat: report genetic interval and terminal value diagnostics

* feat(devices): reconcile flexible profiles and EV deadlines with cycle scheduling

Adapt the flexible consumer primitives from d2e2d582 while retaining the keyed settings and per-cycle scheduling introduced by #1256. Preserve slot battery physics and GENETIC0 flat-load conversion. Cover energy conservation, deadlines, window intersections, DST, completed cycles and EV converters.

* test: satisfy typed genetic PDF chart contracts

* feat(optimization): resolve quarter-hour GENETIC requests from configuration

* test(genetic): verify real device scheduling, measurement and export contracts

Register appliance completed-cycle measurement keys so the real store accepts both default and custom counters. Exercise complete low-budget optimizer runs, persisted measurements, generic solution output and instructions, including zero-power phases, EV departure boundaries, per-cycle windows and LCOS.

* fix: bound genetic report forecasts to executable horizon

* feat: complete native genetic scheduling and retained result contracts

* fix: retain missing raw samples when dropna is disabled

* fix: align local optimization slots and measurement instants

* docs: explain complete genetic rollout and PR dependencies

* feat: expose retained GENETIC report through the versioned API

* docs: regenerate complete genetic configuration and API schema

* docs: format consolidation and review handoff markdown

* test: align isolated EMS fixture with native genetic run options

* test(genetic): clean up singleton measurements after device integration tests

* test: freeze the clock without replacing timestamp conversion

* fix: preserve explicit warmstart timezones in runtime requests

* test(genetic): validate device schedules in UTC and Berlin

Use explicit Berlin origins for Berlin wall-clock windows, compare absolute deadline instants correctly, and run all real device optimizer scenarios under both UTC and Europe/Berlin. Compare exported starts in the run timezone instead of assuming the output timezone matches the host.

* fix: start automatic genetic runs in the site timezone

* Preserve aware GENETIC snapshot times across host timezones

* docs: specify site clock and rehearsed merge resolutions

* test: isolate invalid measurement records and refresh API version

* fix: render single-slot genetic tail diagnostics

* docs: refresh schema version after report fix

* fix: preserve configuration-only Optimize API contract

* docs: refresh configuration request schema

---------

Co-authored-by: Christin <info@bikinibottom.capital>
Co-authored-by: Normann <github@koldrack.com>

---------

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: r0b2g1t <r0b2g1t@users.noreply.github.com>
Co-authored-by: Normann <github@koldrack.com>
Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
Andreas
2026-09-17 20:14:24 +02:00
committed by GitHub
co-authored by Andreas Christin Normann Bobby Noelte r0b2g1t
parent 3c862543a1
commit 04f28997ea
64 changed files with 12540 additions and 1431 deletions
+31
View File
@@ -339,6 +339,37 @@ class SettingsEOSDefaults(SettingsEOS):
# This is mutable, so pydantic does not set a hash.
return id(self)
def validate_optimization_horizons(self) -> "SettingsEOSDefaults":
"""Report a forecast budget that cannot serve the optimization horizons.
This never rejects a configuration. ``prediction.hours`` is a general
setting that also serves callers with nothing to do with optimization,
and refusing it here would stop EOS from starting over a horizon the
user may not even optimize on. A tail that does not fit is simply
shortened, and a control horizon that does not fit is caught by the
optimizer itself, which knows exactly which forecast series ran out.
"""
control = self.optimization.genetic.horizon_hours
tail = self.optimization.genetic.tail_horizon_hours
prediction = self.prediction.hours
if prediction is None or prediction < control:
logger.warning(
"Prediction horizon {} h is shorter than the {} h control horizon. Optimization "
"runs will fail until prediction.hours covers the control horizon.",
prediction,
control,
)
elif prediction < control + tail:
logger.info(
"Prediction horizon {} h covers the {} h control horizon but not the requested "
"{} h tail. The tail is shortened to {} h; raise prediction.hours to use it fully.",
prediction,
control,
tail,
prediction - control,
)
return self
class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""Singleton configuration handler for the EOS application.
@@ -269,6 +269,12 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
# Normalize this provider before the generic field-by-field transfer. Validate
# coupled bounds together so a transient intermediate default cannot lose them.
config_data = dict(config_data)
from akkudoktoreos.optimization.genetic.geneticsettings import (
normalize_genetic_settings,
)
if "optimization" in config_data:
config_data["optimization"] = normalize_genetic_settings(config_data["optimization"])
pv_settings = normalize_akkudoktor_settings(config_data.get("pvforecast"))
if isinstance(pv_settings, dict):
config_data["pvforecast"] = pv_settings
+1 -1
View File
@@ -1137,7 +1137,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecordT], Generic[Da
async for record in self.db_iterate_records(start_timestamp, end_timestamp):
if (
record.date_time is None
or (getattr(record, key, None) is None) # key is not in record
or not hasattr(record, key) # key is not in record
or (dropna and pd.isna(getattr(record, key, None)))
):
continue
+14 -8
View File
@@ -2234,7 +2234,9 @@ class EnergyManagementPlan(PydanticBaseModel):
self.valid_until = None
return
self.valid_from = min(i.execution_time for i in self.instructions)
self.valid_from = min(
(i.execution_time for i in self.instructions), key=lambda value: value.timestamp()
)
end_times = []
for instr in self.instructions:
@@ -2245,12 +2247,14 @@ class EnergyManagementPlan(PydanticBaseModel):
return
end_times.append(instr.execution_time + instr_duration)
self.valid_until = max(end_times) if end_times else None
self.valid_until = (
max(end_times, key=lambda value: value.timestamp()) if end_times else None
)
def add_instruction(self, instruction: EnergyManagementInstruction) -> None:
"""Adds a new control instruction and updates time range."""
self.instructions.append(instruction)
self.instructions.sort(key=lambda i: i.execution_time)
self.instructions.sort(key=lambda i: i.execution_time.timestamp())
self._update_time_range()
def clear(self) -> None:
@@ -2289,14 +2293,14 @@ class EnergyManagementPlan(PydanticBaseModel):
by_resource: dict[str, list["EnergyManagementInstruction"]] = defaultdict(list)
for instr in self.instructions:
# skip instructions scheduled in the future
if instr.execution_time <= now:
if instr.execution_time.timestamp() <= now.timestamp():
by_resource[instr.resource_id].append(instr)
active: list["EnergyManagementInstruction"] = []
for resource_id, instrs in by_resource.items():
# pick latest instruction by execution_time
latest = max(instrs, key=lambda i: i.execution_time)
latest = max(instrs, key=lambda i: i.execution_time.timestamp())
if len(instrs) == 0:
# No instructions, ther shall be at least one
@@ -2310,7 +2314,7 @@ class EnergyManagementPlan(PydanticBaseModel):
active.append(latest)
else:
# active only if now is strictly before execution_time + duration
if latest.execution_time + instr_duration > now:
if (latest.execution_time + instr_duration).timestamp() > now.timestamp():
active.append(latest)
return active
@@ -2320,9 +2324,11 @@ class EnergyManagementPlan(PydanticBaseModel):
) -> Optional[EnergyManagementInstruction]:
"""Finds the next instruction scheduled after the specified time."""
now = now or to_datetime()
future_instructions = [i for i in self.instructions if i.execution_time > now]
future_instructions = [
i for i in self.instructions if i.execution_time.timestamp() > now.timestamp()
]
return (
min(future_instructions, key=lambda i: i.execution_time)
min(future_instructions, key=lambda i: i.execution_time.timestamp())
if future_instructions
else None
)
+58 -12
View File
@@ -22,6 +22,7 @@ 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,
@@ -63,6 +64,7 @@ class EnergyManagement(
# 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
@@ -101,25 +103,47 @@ class EnergyManagement(
"""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) -> DateTime:
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 datetime is always rounded down to the nearest hour
(i.e., setting minutes, seconds, and microseconds to zero).
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._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
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
@@ -172,8 +196,11 @@ class EnergyManagement(
start_datetime: Optional[DateTime] = None,
mode: Optional[EnergyManagementMode] = None,
algorithm: Optional[OptimizationAlgorithm] = None,
genetic_parameters: Optional[GeneticOptimizationParameters] = 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,
@@ -207,6 +234,7 @@ class EnergyManagement(
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
@@ -245,7 +273,20 @@ class EnergyManagement(
# Remember/ set the start datetime of this energy management run.
# None leads to current time as start datetime
self.set_start_datetime(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()
@@ -296,15 +337,19 @@ class EnergyManagement(
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
if genetic_parameters is None:
genetic_parameters = await GeneticOptimizationParameters.prepare()
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(
f"{algorithm}: Energy management run canceled. "
"Could not prepare optimisation parameters."
)
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:
@@ -332,6 +377,7 @@ class EnergyManagement(
GeneticOptimizationParameters, genetic_parameters
), # cast for mypy
ngen=genetic_generations,
individuals=genetic_individuals,
),
)
+111
View File
@@ -1,6 +1,8 @@
"""Abstract and base classes for devices."""
import math
from enum import StrEnum
from typing import Optional
class BatteryOperationMode(StrEnum):
@@ -71,6 +73,115 @@ class BatteryOperationMode(StrEnum):
FAULT = "FAULT"
def validate_home_appliance_load_definition(
*,
load_profile_power_w: Optional[list[float]],
load_profile_interval_seconds: Optional[int],
consumption_wh: Optional[float],
duration_h: Optional[float],
) -> None:
"""Validate the load definition of a flexible consumer / home appliance.
A consumer's load must be given **either** as a full explicit power profile
(``load_profile_power_w``) **or** as the complete flat fallback
(``consumption_wh`` together with ``duration_h``). Providing both, or only a
part of the fallback, is rejected. Profile values must be finite and
non-negative and the profile interval, if given, must be positive.
Args:
load_profile_power_w: Explicit per-step power values [W], or None.
load_profile_interval_seconds: Duration of one profile step [s], or None.
consumption_wh: Fallback total energy of one run [Wh], or None.
duration_h: Fallback run duration [h], or None.
Raises:
ValueError: If the definition is conflicting, incomplete, or contains
invalid profile values.
"""
profile_given = load_profile_power_w is not None
fallback_fields = (consumption_wh, duration_h)
fallback_partial = any(field is not None for field in fallback_fields)
fallback_given = all(field is not None for field in fallback_fields)
if profile_given and fallback_partial:
raise ValueError(
"Conflicting home appliance load definition: provide either "
"load_profile_power_w or consumption_wh together with duration_h, "
"not both."
)
if load_profile_power_w is None:
if not fallback_given:
raise ValueError(
"Incomplete home appliance load definition: provide a full "
"load_profile_power_w or both consumption_wh and duration_h."
)
# Value ranges of the fallback fields are enforced by their Field
# constraints (gt=0); nothing more to check here.
return
# Explicit profile path.
if load_profile_interval_seconds is not None and load_profile_interval_seconds <= 0:
raise ValueError("load_profile_interval_seconds must be greater than zero.")
if len(load_profile_power_w) == 0:
raise ValueError("load_profile_power_w must not be empty.")
for value in load_profile_power_w:
if value is None or math.isnan(value) or math.isinf(value):
raise ValueError(
"load_profile_power_w must contain only finite values (no NaN or infinity)."
)
if value < 0:
raise ValueError("load_profile_power_w must not contain negative values.")
class ConsumerScheduleMode(StrEnum):
"""Schedule mode of a flexible consumer (home appliance).
Determines how often a consumer's load profile is scheduled within the
optimization horizon.
Modes
-----
- ONCE:
The consumer runs exactly once somewhere within the optimization
horizon ("fire and forget"). The optimizer picks the start.
- DAILY:
The consumer runs once per local calendar day, but only on days for
which at least one complete, allowed run still fits into the remaining
horizon. The optimizer picks one start per eligible day.
"""
ONCE = "ONCE"
DAILY = "DAILY"
class ConsumerDeadlinePolicy(StrEnum):
"""Behaviour when a flexible consumer's deadline cannot be met.
A deadline (``deadline_datetime``) demands that a complete run has *finished*
before that moment. Depending on "now", the run duration, the optimization
horizon and the allowed time windows, no such start may exist.
Policies
--------
- BEST_EFFORT:
Run as early as the remaining constraints allow, i.e. minimize the
delay instead of the cost ("it should have been done by 03:00, so
start now"). A warning is logged. This keeps an optimization request
answerable instead of failing it - the usual choice for home
automation.
- STRICT:
Keep the deadline. A ONCE consumer without a feasible start makes the
optimization fail; a DAILY consumer is simply not scheduled on days
without a feasible start.
"""
BEST_EFFORT = "BEST_EFFORT"
STRICT = "STRICT"
class ApplianceOperationMode(StrEnum):
"""Appliance operation modes.
+35 -1
View File
@@ -1,10 +1,11 @@
from typing import Any, Iterator, Optional
import numpy as np
from pydantic import Field
from pydantic import Field, field_validator
from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
def max_charging_power_field(description: Optional[str] = None) -> float:
@@ -122,6 +123,39 @@ class ElectricVehicleParameters(BaseBatteryParameters):
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
min_soc_deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute moment by which 'min_soc_percentage' has to be "
"reached (departure time). A date time without timezone is read "
"as local time. None means end of the optimization horizon."
),
"examples": [None, "2026-07-16T07:00:00+02:00"],
},
)
min_soc_max_duration_h: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"Maximum time from the start of the optimization until "
"'min_soc_percentage' has to be reached [h]. Combined with "
"'min_soc_deadline_datetime' the earlier of the two applies."
),
"examples": [None, 6.0],
},
)
@field_validator("min_soc_deadline_datetime", mode="before")
@classmethod
def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
class Battery:
"""Represents a battery device with methods to simulate energy charging and discharging."""
@@ -5,7 +5,7 @@ machines that must run for a fixed duration, possibly multiple times
(cycles), within one or more allowed time windows. Given a set of
requested start times, `HomeAppliance` repairs them into a
feasible, chronologically ordered schedule and produces the resulting
hourly load curve.
per-slot energy curve.
Time windows are always expressed as a `CycleTimeWindowSequence`
(see ``akkudoktoreos.config.configabc``): each contained window's
@@ -29,22 +29,92 @@ Cycle start times are repaired according to the following rules:
5. Generate the combined hourly load curve from the final starts.
"""
from typing import Optional
import math
from collections.abc import Sequence
from typing import Any, Optional, Self
import numpy as np
from pydantic import Field
from loguru import logger
from pydantic import Field, field_validator, model_validator
from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow
from akkudoktoreos.config.configabc import (
CycleTimeWindowSequence,
TimeWindow,
TimeWindowSequence,
ValueTimeWindow,
)
from akkudoktoreos.devices.devicesabc import (
ConsumerDeadlinePolicy,
ConsumerScheduleMode,
validate_home_appliance_load_definition,
)
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
compare_datetimes,
to_datetime,
to_duration,
to_time,
)
def resample_power_to_slot_energy(
power_w: list[float],
input_interval_seconds: float,
slot_interval_seconds: float,
) -> np.ndarray:
"""Resample a piecewise-constant power profile to per-slot energy.
Each input value ``power_w[i]`` is interpreted as a constant power [W] over
the interval ``[i * input_interval_seconds, (i + 1) * input_interval_seconds)``.
The energy of every output slot is the time-weighted integral of the input
power over that slot::
E_j = sum_i P_i * overlap(i, j) / 3600 [Wh]
where ``overlap(i, j)`` is the temporal overlap (in seconds) between input
interval ``i`` and output slot ``j``. This is exact for arbitrary (including
non-integer) ratios such as 10 -> 15 or 20 -> 15 minutes and conserves
energy within numerical tolerance::
sum_j E_j == sum_i P_i * input_interval_seconds / 3600
Args:
power_w: Piecewise-constant power values [W] of a single run.
input_interval_seconds: Duration of one input step [s] (> 0).
slot_interval_seconds: Duration of one output slot [s] (> 0).
Returns:
1-D array of per-slot energy [Wh]; length is the number of slots the run
occupies (ceil of the total run duration divided by the slot duration).
"""
if not math.isfinite(input_interval_seconds) or input_interval_seconds <= 0:
raise ValueError("Input interval must be finite and positive.")
if not math.isfinite(slot_interval_seconds) or slot_interval_seconds <= 0:
raise ValueError("Slot interval must be finite and positive.")
if not power_w or any(not math.isfinite(p) or p < 0 for p in power_w):
raise ValueError("Power profile must contain finite non-negative values.")
n_in = len(power_w)
total_seconds = n_in * input_interval_seconds
n_slots = int(np.ceil(total_seconds / slot_interval_seconds - 1e-9))
out = np.zeros(max(n_slots, 0), dtype=float)
for i, power in enumerate(power_w):
if power == 0.0:
continue
seg_start = i * input_interval_seconds
seg_end = seg_start + input_interval_seconds
first = int(seg_start // slot_interval_seconds)
last = int((seg_end - 1e-9) // slot_interval_seconds)
for j in range(first, last + 1):
slot_start = j * slot_interval_seconds
slot_end = slot_start + slot_interval_seconds
overlap = min(seg_end, slot_end) - max(seg_start, slot_start)
if overlap > 0:
out[j] += power * overlap / 3600.0
return out
class HomeApplianceParameters(DeviceParameters):
"""Configuration for a simulated home appliance device."""
@@ -54,7 +124,8 @@ class HomeApplianceParameters(DeviceParameters):
"examples": ["dishwasher"],
}
)
consumption_wh: int = Field(
consumption_wh: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
@@ -64,7 +135,8 @@ class HomeApplianceParameters(DeviceParameters):
"examples": [2000],
},
)
duration_h: int = Field(
duration_h: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
@@ -81,6 +153,10 @@ class HomeApplianceParameters(DeviceParameters):
"examples": [2],
},
)
completed_cycles: int = Field(
default=0, ge=0, description="Cycles already completed on the first planning day."
)
min_cycle_gap_h: int = Field(
default=0,
ge=0,
@@ -113,6 +189,112 @@ class HomeApplianceParameters(DeviceParameters):
},
)
shared_time_windows: Optional[TimeWindowSequence] = Field(
default=None,
description="Allowed recurring windows shared by every cycle; intersected with per-cycle windows.",
)
load_profile_power_w: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Explicit load profile describing a single complete run as a "
"sequence of non-negative power values in watts. Each value "
"covers 'load_profile_interval_seconds'. Mutually exclusive with "
"consumption_wh/duration_h."
),
"examples": [[200.0, 2000.0, 1800.0, 100.0]],
},
)
load_profile_interval_seconds: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Duration of one 'load_profile_power_w' step in seconds. Defaults "
"to the configured optimization interval when a profile is given."
),
"examples": [900, 3600],
},
)
schedule_mode: ConsumerScheduleMode = Field(
default=ConsumerScheduleMode.ONCE,
json_schema_extra={
"description": (
"Scheduling mode: ONCE (a single run within the horizon) or DAILY "
"(one run per local calendar day with a feasible full run)."
),
"examples": ["ONCE", "DAILY"],
},
)
earliest_start_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute earliest moment the run may start. Starts before it are "
"dropped, in addition to 'time_windows' and the horizon. A date "
"time without timezone is read as local time. This bound is never "
"relaxed."
),
"examples": [None, "2026-07-15T20:00:00+02:00"],
},
)
deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute deadline: the complete run must have *finished* at or "
"before this moment (e.g. end of the day, or 03:00 tonight). A "
"date time without timezone is read as local time. See "
"'deadline_policy' for what happens when no start can meet it."
),
"examples": [None, "2026-07-16T03:00:00+02:00"],
},
)
deadline_policy: ConsumerDeadlinePolicy = Field(
default=ConsumerDeadlinePolicy.BEST_EFFORT,
json_schema_extra={
"description": (
"What to do when 'deadline_datetime' cannot be met: BEST_EFFORT "
"runs as early as possible instead (warning logged), STRICT keeps "
"the deadline (a ONCE consumer then fails the optimization)."
),
"examples": ["BEST_EFFORT", "STRICT"],
},
)
@field_validator("earliest_start_datetime", "deadline_datetime", mode="before")
@classmethod
def transform_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
@model_validator(mode="after")
def validate_load_definition(self) -> Self:
"""Ensure a complete load definition and valid completed-cycle count."""
if self.completed_cycles > self.num_cycles:
raise ValueError("completed_cycles must not exceed num_cycles.")
validate_home_appliance_load_definition(
load_profile_power_w=self.load_profile_power_w,
load_profile_interval_seconds=self.load_profile_interval_seconds,
consumption_wh=self.consumption_wh,
duration_h=self.duration_h,
)
return self
@model_validator(mode="after")
def validate_schedule_bounds(self) -> Self:
"""Reject an empty scheduling interval."""
if self.earliest_start_datetime is not None and self.deadline_datetime is not None:
if compare_datetimes(self.deadline_datetime, self.earliest_start_datetime).le:
raise ValueError(
f"deadline_datetime {self.deadline_datetime} must be after "
f"earliest_start_datetime {self.earliest_start_datetime}."
)
return self
class HomeAppliance:
"""Non-vectorized simulation of a multi-cycle home appliance.
@@ -127,6 +309,7 @@ class HomeAppliance:
parameters: HomeApplianceParameters,
optimization_hours: int,
prediction_hours: int,
slot_duration_h: float = 1.0,
) -> None:
"""Initializes the appliance and builds its allowed-start masks.
@@ -135,18 +318,32 @@ class HomeAppliance:
optimization_hours: Number of hours under active
optimization.
prediction_hours: Length of the simulation horizon, in
hours.
slots.
slot_duration_h: Physical duration of each slot, in hours.
"""
self.parameters = parameters
self.optimization_hours = optimization_hours
self.prediction_hours = prediction_hours
self.duration_h = parameters.duration_h
self.consumption_wh = parameters.consumption_wh
if not math.isfinite(slot_duration_h) or slot_duration_h <= 0:
raise ValueError("Slot duration must be finite and positive.")
self.total_slots = prediction_hours
self.slot_duration_h = slot_duration_h
self.slot_interval_seconds = slot_duration_h * 3600
self.device_id = parameters.device_id
self.schedule_mode = parameters.schedule_mode
self.time_windows = parameters.shared_time_windows
self.earliest_start_datetime = parameters.earliest_start_datetime
self.deadline_datetime = parameters.deadline_datetime
self.deadline_policy = parameters.deadline_policy
self.deadline_relaxed = False
self._build_run_profile()
self.duration_h = self.run_slots * slot_duration_h
self.consumption_wh = float(self.run_energy_wh.sum())
self.num_cycles = parameters.num_cycles
self.min_cycle_gap_h = parameters.min_cycle_gap_h
self.completed_cycles = 0
self.completed_cycles = parameters.completed_cycles
self.load_curve = np.zeros(prediction_hours)
@@ -173,6 +370,280 @@ class HomeAppliance:
self._setup()
def _build_run_profile(self) -> None:
"""Build the per-slot energy [Wh] of a single complete run."""
if self.parameters.load_profile_power_w is not None:
power = [float(value) for value in self.parameters.load_profile_power_w]
input_interval = (
self.parameters.load_profile_interval_seconds or self.slot_interval_seconds
)
else:
# Flat fallback: constant power over duration_h hours. Route it through
# the same resampling path so hourly and sub-hourly grids behave
# identically. Power [W] = energy per hour = consumption_wh / duration_h.
duration_h = self.parameters.duration_h
consumption_wh = self.parameters.consumption_wh
if duration_h is None or consumption_wh is None:
raise ValueError("Flat appliance load requires duration and consumption.")
power = [consumption_wh / duration_h]
input_interval = duration_h * 3600
self.run_energy_wh: np.ndarray = resample_power_to_slot_energy(
power, float(input_interval), float(self.slot_interval_seconds)
)
self.run_slots: int = int(len(self.run_energy_wh))
def _slot_offset(self, moment: DateTime, slot0_datetime: DateTime, *, round_up: bool) -> int:
"""Convert an absolute moment into a slot index relative to slot 0.
Args:
moment: Absolute moment; converted into ``slot0_datetime``'s timezone.
slot0_datetime: Local, timezone-aware datetime of slot index 0.
round_up: ``True`` returns the first slot boundary at or after
``moment`` (lower bounds), ``False`` the last one at or before
it (upper bounds).
Returns:
Slot index (may be negative or beyond the grid; callers clamp).
"""
timezone = slot0_datetime.timezone
if timezone is None:
raise ValueError("The optimization slot origin must have a timezone.")
seconds = (moment.in_timezone(timezone) - slot0_datetime).total_seconds()
exact = seconds / self.slot_interval_seconds
# Tolerance absorbs float noise so a moment that sits exactly on a slot
# boundary is not pushed to the neighbouring slot.
return math.ceil(exact - 1e-9) if round_up else math.floor(exact + 1e-9)
def allowed_start_slots(
self,
*,
slot0_datetime: DateTime,
earliest_slot: int,
horizon_end_slot: int,
cycle_index: Optional[int] = None,
) -> list[int]:
"""Return the sorted absolute start slots at which a full run is allowed.
A start slot ``s`` is allowed when the complete run fits the optimization
horizon, both absolute bounds and (if configured) a single allowed time
window:
- ``earliest_slot <= s`` and ``s + run_slots <= horizon_end_slot``
- with ``earliest_start_datetime`` set, the run starts at or after it
- with ``deadline_datetime`` set, the run *ends* at or before it
- with ``time_windows`` set, the run's whole occupied span starting at
``s`` is contained in one window (respecting weekday/date constraints)
When a deadline leaves no start at all and the policy is
``BEST_EFFORT``, the deadline is dropped and only the earliest still
possible start is offered (a warning is logged and ``deadline_relaxed``
is set). Multiple cycles retain all relaxed choices for joint
earliest-start repair by the optimizer, respecting per-cycle windows
and minimum gaps.
No snapping is performed: every returned slot is a genuinely valid start.
Args:
slot0_datetime: Local, timezone-aware datetime of slot index 0.
earliest_slot: First slot the optimizer may schedule at ("now").
horizon_end_slot: Exclusive upper bound; a run must end at or before.
cycle_index: Global configured cycle index, independent of completed cycles.
Returns:
Sorted list of allowed absolute start slots (may be empty).
"""
self.deadline_relaxed = False
allowed = self._allowed_start_slots(
slot0_datetime=slot0_datetime,
earliest_slot=earliest_slot,
horizon_end_slot=horizon_end_slot,
apply_deadline=True,
cycle_index=cycle_index,
)
if (
allowed
or self.deadline_datetime is None
or self.deadline_policy == ConsumerDeadlinePolicy.STRICT
):
return allowed
relaxed = self._allowed_start_slots(
slot0_datetime=slot0_datetime,
earliest_slot=earliest_slot,
horizon_end_slot=horizon_end_slot,
apply_deadline=False,
cycle_index=cycle_index,
)
if not relaxed:
return relaxed
self.deadline_relaxed = True
# Keep only the earliest possible start: the deadline is already missed,
# so the run is scheduled as soon as possible rather than as cheap as
# possible. Multiple cycles retain choices so the optimizer can find
# the earliest joint schedule that respects idle gaps.
earliest = relaxed[:1] if self.num_cycles == 1 else relaxed
logger.warning(
"Home appliance '{}': deadline {} can not be met - running as early as "
"possible instead (BEST_EFFORT). Run ends {}.",
self.device_id,
self.deadline_datetime,
self.run_end_datetime(earliest[0], slot0_datetime),
)
return earliest
def _allowed_start_slots(
self,
*,
slot0_datetime: DateTime,
earliest_slot: int,
horizon_end_slot: int,
cycle_index: Optional[int] = None,
apply_deadline: bool,
) -> list[int]:
"""Compute the allowed start slots for one set of constraints.
Args:
slot0_datetime: Local, timezone-aware datetime of slot index 0.
earliest_slot: First slot the optimizer may schedule at ("now").
horizon_end_slot: Exclusive upper bound; a run must end at or before.
cycle_index: Global configured cycle index, independent of completed cycles.
apply_deadline: Whether ``deadline_datetime`` restricts the run end.
Returns:
Sorted list of allowed absolute start slots (may be empty).
"""
run_slots = self.run_slots
if run_slots <= 0:
return []
first_start = max(earliest_slot, 0)
if self.earliest_start_datetime is not None:
first_start = max(
first_start,
self._slot_offset(self.earliest_start_datetime, slot0_datetime, round_up=True),
)
end_bound = min(horizon_end_slot, self.total_slots)
if apply_deadline and self.deadline_datetime is not None:
end_bound = min(
end_bound,
self._slot_offset(self.deadline_datetime, slot0_datetime, round_up=False),
)
last_start = end_bound - run_slots
if last_start < first_start:
return []
if cycle_index is not None and not 0 <= cycle_index < self.num_cycles:
raise ValueError("Cycle index is outside the configured cycle range.")
cycle_windows = [
window
for window in (
self.parameters.time_windows.windows if self.parameters.time_windows else []
)
if window.value is not None and int(window.value) == cycle_index
]
run_duration = to_duration(f"{run_slots * self.slot_interval_seconds} seconds")
allowed: list[int] = []
for slot in range(first_start, last_start + 1):
start_dt = slot0_datetime.add(seconds=slot * self.slot_interval_seconds)
if (
self.time_windows is None
or self._windows_allow_run(self.time_windows.windows, start_dt, run_duration)
) and (
not cycle_windows
or self._windows_allow_run(cycle_windows, start_dt, run_duration, merge=True)
):
allowed.append(slot)
return allowed
@staticmethod
def _windows_allow_run(
windows: Sequence[TimeWindow], start: DateTime, duration: Duration, *, merge: bool = False
) -> bool:
"""Check full coverage including windows anchored on previous local dates.
Date and weekday restrictions belong to the window's opening day.
Per-cycle windows retain their existing union semantics; shared windows
require one complete containing occurrence.
"""
intervals: list[tuple[DateTime, DateTime]] = []
run_end = start + duration
for window in windows:
days_back = math.ceil(window.duration.total_seconds() / 86400) + 1
for offset in range(days_back + 1):
anchor = start.subtract(days=offset)
if window.date is not None and anchor.date() != window.date:
continue
if window.day_of_week is not None and anchor.day_of_week != window.day_of_week:
continue
opening, closing = window._window_start_end(anchor)
if opening <= start and run_end <= closing:
return True
if merge and opening < run_end and closing > start:
intervals.append((opening, closing))
covered_until = start
for opening, closing in sorted(intervals):
if opening > covered_until:
break
covered_until = max(covered_until, closing)
if covered_until >= run_end:
return True
return False
def run_end_datetime(self, start_slot: int, slot0_datetime: DateTime) -> DateTime:
"""Absolute local moment at which a run started at ``start_slot`` finishes.
Args:
start_slot: Absolute start slot of the run.
slot0_datetime: Local, timezone-aware datetime of slot index 0.
Returns:
End datetime of the run (exclusive, i.e. the first free moment).
"""
return slot0_datetime.add(
seconds=(start_slot + self.run_slots) * self.slot_interval_seconds
)
def deadline_missed(self, starts: list[int], slot0_datetime: DateTime) -> bool:
"""Whether the scheduled runs violate the configured deadline.
Without a deadline nothing can be missed. With one, a consumer that was
not scheduled at all, or whose run ends after the deadline (a relaxed
BEST_EFFORT deadline), counts as missed.
Args:
starts: Absolute start slots of the scheduled runs.
slot0_datetime: Local, timezone-aware datetime of slot index 0.
Returns:
True if the deadline is set and not met.
"""
if self.deadline_datetime is None:
return False
if not starts:
return True
timezone = slot0_datetime.timezone
if timezone is None:
raise ValueError("The optimization slot origin must have a timezone.")
deadline = self.deadline_datetime.in_timezone(timezone)
return any(self.run_end_datetime(start, slot0_datetime) > deadline for start in starts)
def build_load_curve(self, starts: list[int]) -> None:
"""Place the resampled run energy at each decoded start slot.
Multiple runs may overlap; their per-slot energies are summed.
Args:
starts: Absolute start slots of the scheduled runs.
"""
if any(start < 0 or start + self.run_slots > self.total_slots for start in starts):
raise ValueError("A complete appliance run must fit inside the slot horizon.")
self.start_hours = list(starts)
self._build_load_curve()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
@@ -257,9 +728,9 @@ class HomeAppliance:
microsecond=0,
)
end_datetime = start_datetime.add(
hours=self.prediction_hours,
seconds=self.prediction_hours * self.slot_interval_seconds,
)
interval = to_duration("1 hour")
interval = to_duration(self.slot_interval_seconds)
self._build_cycle_start_allowed(
self.parameters.time_windows,
@@ -293,7 +764,7 @@ class HomeAppliance:
max_start = max(
0,
self.prediction_hours - self.duration_h,
self.prediction_hours - self.run_slots,
)
# The matrix tells us which individual *steps* are inside
@@ -348,7 +819,7 @@ class HomeAppliance:
horizon = len(window_steps)
max_start = max(
0,
horizon - self.duration_h,
horizon - self.run_slots,
)
allowed = np.zeros(
@@ -356,15 +827,15 @@ class HomeAppliance:
dtype=bool,
)
if self.duration_h > horizon:
if self.run_slots > horizon:
return allowed
# Rolling sum of `duration_h` consecutive steps, aligned so
# that window_sums[s] == sum(window_steps[s : s + duration_h]).
cumulative = np.concatenate(([0.0], np.cumsum(window_steps)))
window_sums = cumulative[self.duration_h :] - cumulative[: -self.duration_h]
window_sums = cumulative[self.run_slots :] - cumulative[: -self.run_slots]
allowed[: max_start + 1] = window_sums[: max_start + 1] == float(self.duration_h)
allowed[: max_start + 1] = window_sums[: max_start + 1] == float(self.run_slots)
return allowed
@@ -404,7 +875,7 @@ class HomeAppliance:
max_start = max(
0,
self.prediction_hours - self.duration_h,
self.prediction_hours - self.run_slots,
)
# 1. Round and clip.
@@ -442,7 +913,7 @@ class HomeAppliance:
# 4. Enforce duration + minimum idle gap. Each cycle is
# pushed forward, if needed, to the next start allowed by
# its *own* window.
min_next_start = self.duration_h + self.min_cycle_gap_h
min_next_start = self.run_slots + math.ceil(self.min_cycle_gap_h / self.slot_duration_h)
final_starts = [repaired[0][0]]
@@ -582,25 +1053,17 @@ class HomeAppliance:
"""Builds the load curve from all scheduled cycles."""
self.reset_load_curve()
power_per_hour = self.consumption_wh / self.duration_h
for start_hour in self.start_hours:
if start_hour >= self.prediction_hours:
continue
end_hour = min(
start_hour + self.duration_h,
self.prediction_hours,
)
self.load_curve[start_hour:end_hour] += power_per_hour
for start in self.start_hours:
length = min(self.run_slots, self.total_slots - start)
if 0 <= start and length > 0:
self.load_curve[start : start + length] += self.run_energy_wh[:length]
def reset_load_curve(self) -> None:
"""Resets the load curve to all zeros."""
self.load_curve = np.zeros(self.prediction_hours)
def get_load_curve(self) -> np.ndarray:
"""Returns the current hourly load curve, in watts."""
"""Returns the current per-slot load curve, in watt-hours."""
return self.load_curve
def get_load_for_hour(self, hour: int) -> float:
@@ -610,7 +1073,7 @@ class HomeAppliance:
hour: Hour of the prediction horizon to look up.
Returns:
The load, in watts, at ``hour``.
The energy, in watt-hours, at slot ``hour``.
Raises:
ValueError: If ``hour`` is outside
@@ -10,11 +10,13 @@ import numpy as np
from numpydantic import NDArray, Shape
from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import ConfigScope
from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings
from akkudoktoreos.measurement.batterycapacity import (
BatteryCapacityEstimate,
BatteryCapacityEstimationSettings,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
if TYPE_CHECKING:
from akkudoktoreos.devices.genetic0.genetic0battery import (
@@ -175,6 +177,41 @@ class BatteriesCommonSettings(DevicesBaseSettings):
return arr.tolist()
min_soc_deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Absolute moment by which 'min_soc_percentage' has to be "
"reached (departure time). A date time without timezone is read "
"as local time. None means end of the optimization horizon."
),
"examples": [None, "2026-07-16T07:00:00+02:00"],
},
)
min_soc_max_duration_h: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Maximum time from the start of the optimization until "
"'min_soc_percentage' has to be reached [h]. Combined with "
"'min_soc_deadline_datetime' the earlier of the two applies."
),
"examples": [None, 6.0],
},
)
@field_validator("min_soc_deadline_datetime", mode="before")
@classmethod
def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
@@ -205,6 +242,8 @@ class BatteriesCommonSettings(DevicesBaseSettings):
max_charge_power_w=self.max_charge_power_w,
min_soc_percentage=self.min_soc_percentage,
max_soc_percentage=self.max_soc_percentage,
min_soc_deadline_datetime=self.min_soc_deadline_datetime,
min_soc_max_duration_h=self.min_soc_max_duration_h,
)
# ------------------------------------------------------------------
@@ -3,12 +3,22 @@
Note: Used for the GENETIC and GENETIC0 algorithm.
"""
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Any, Optional, Self
from pydantic import Field, computed_field, model_validator
from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import ConfigScope, CycleTimeWindowSequence
from akkudoktoreos.config.configabc import (
ConfigScope,
CycleTimeWindowSequence,
TimeWindowSequence,
)
from akkudoktoreos.devices.devicesabc import (
ConsumerDeadlinePolicy,
ConsumerScheduleMode,
validate_home_appliance_load_definition,
)
from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
if TYPE_CHECKING:
from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
@@ -105,7 +115,7 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
"""
consumption_wh: int = Field(
consumption_wh: Optional[int] = Field(
default=3000,
gt=0,
json_schema_extra={
@@ -114,7 +124,7 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
duration_h: int = Field(
duration_h: Optional[int] = Field(
default=3,
gt=0,
le=24,
@@ -193,6 +203,133 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
},
)
@model_validator(mode="before")
@classmethod
def _profile_replaces_flat_defaults(cls, value: Any) -> Any:
"""Keep historical flat defaults unless an explicit profile is supplied."""
if isinstance(value, dict) and value.get("load_profile_power_w") is not None:
value = dict(value)
value.setdefault("consumption_wh", None)
value.setdefault("duration_h", None)
return value
load_profile_power_w: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Explicit load profile describing a single complete run as a "
"sequence of non-negative power values in watts. Each value "
"covers 'load_profile_interval_seconds'. Mutually exclusive with "
"consumption_wh/duration_h."
),
"examples": [[200.0, 2000.0, 1800.0, 100.0]],
},
)
load_profile_interval_seconds: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Duration of one 'load_profile_power_w' step in seconds. Defaults "
"to the configured optimization interval when a profile is given."
),
"examples": [900, 3600],
},
)
schedule_mode: ConsumerScheduleMode = Field(
default=ConsumerScheduleMode.ONCE,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Scheduling mode: ONCE (a single run within the horizon) or DAILY "
"(one run per local calendar day with a feasible full run)."
),
"examples": ["ONCE", "DAILY"],
},
)
time_windows: Optional[TimeWindowSequence] = Field(
default=None,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": "List of allowed time windows. Defaults to optimization general time window.",
"examples": [
[
{"start_time": "10:00", "duration": "3 hours"},
],
],
},
)
earliest_start_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Absolute earliest moment the run may start. Starts before it are "
"dropped, in addition to 'time_windows' and the horizon. A date "
"time without timezone is read as local time. This bound is never "
"relaxed."
),
"examples": [None, "2026-07-15T20:00:00+02:00"],
},
)
deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"Absolute deadline: the complete run must have *finished* at or "
"before this moment (e.g. end of the day, or 03:00 tonight). A "
"date time without timezone is read as local time. See "
"'deadline_policy' for what happens when no start can meet it."
),
"examples": [None, "2026-07-16T03:00:00+02:00"],
},
)
deadline_policy: ConsumerDeadlinePolicy = Field(
default=ConsumerDeadlinePolicy.BEST_EFFORT,
json_schema_extra={
"x-scope": [str(ConfigScope.GENETIC)],
"description": (
"What to do when 'deadline_datetime' cannot be met: BEST_EFFORT "
"runs as early as possible instead (warning logged), STRICT keeps "
"the deadline (a ONCE consumer then fails the optimization)."
),
"examples": ["BEST_EFFORT", "STRICT"],
},
)
@field_validator("earliest_start_datetime", "deadline_datetime", mode="before")
@classmethod
def transform_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
@model_validator(mode="after")
def validate_load_definition(self) -> Self:
"""Ensure exactly one complete, valid load definition is provided."""
validate_home_appliance_load_definition(
load_profile_power_w=self.load_profile_power_w,
load_profile_interval_seconds=self.load_profile_interval_seconds,
consumption_wh=self.consumption_wh,
duration_h=self.duration_h,
)
return self
@model_validator(mode="after")
def validate_schedule_bounds(self) -> Self:
"""Reject an empty scheduling interval."""
if self.earliest_start_datetime is not None and self.deadline_datetime is not None:
if compare_datetimes(self.deadline_datetime, self.earliest_start_datetime).le:
raise ValueError(
f"deadline_datetime {self.deadline_datetime} must be after "
f"earliest_start_datetime {self.earliest_start_datetime}."
)
return self
@model_validator(mode="after")
def _validate_num_cycles_specified(self) -> "HomeApplianceCommonSettings":
"""Require num_cycles when windows are not provided."""
@@ -231,6 +368,13 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
num_cycles=self.effective_num_cycles,
min_cycle_gap_h=self.min_cycle_gap_h,
time_windows=self.cycle_time_windows,
shared_time_windows=self.time_windows,
load_profile_power_w=self.load_profile_power_w,
load_profile_interval_seconds=self.load_profile_interval_seconds,
schedule_mode=self.schedule_mode,
earliest_start_datetime=self.earliest_start_datetime,
deadline_datetime=self.deadline_datetime,
deadline_policy=self.deadline_policy,
)
# ------------------------------------------------------------------
@@ -243,6 +387,10 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
Genetic0HomeApplianceParameters,
)
if self.load_profile_power_w is not None:
raise ValueError("Explicit appliance load profiles require the GENETIC optimizer.")
if self.consumption_wh is None or self.duration_h is None:
raise ValueError("GENETIC0 requires a flat appliance load definition.")
return Genetic0HomeApplianceParameters(
device_id=self.device_id,
consumption_wh=self.consumption_wh,
@@ -254,5 +402,4 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the home appliance stati that are measurements."""
keys: list[str] = []
return keys
return [self.cycles_completed_measurement_key or f"{self.device_id}.cycles_completed"]
@@ -0,0 +1,241 @@
"""Configuration-owned GENETIC requests with fresh runtime observations."""
import math
from typing import Annotated, Any, Optional
from pydantic import AliasChoices, Field, field_validator
from akkudoktoreos.core.coreabc import (
ConfigMixin,
MeasurementMixin,
PredictionMixin,
get_ems,
)
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticEnergyManagementParameters,
GeneticOptimizationParameters,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
class RuntimeForecasts(GeneticParametersBaseModel):
"""External series from local midnight, in slot Wh and currency per Wh.
The interval is configured in optimization.genetic.interval_sec. Omitted
series are read from the configured providers, preserving their raw coverage.
"""
pv_forecast_wh: Optional[list[float]] = Field(
default=None, validation_alias=AliasChoices("pv_forecast_wh", "pv_prognose_wh")
)
total_load: Optional[list[float]] = Field(
default=None, validation_alias=AliasChoices("total_load", "gesamtlast")
)
electricity_price_per_wh: Optional[list[float]] = Field(
default=None,
validation_alias=AliasChoices("electricity_price_per_wh", "strompreis_euro_pro_wh"),
)
feed_in_tariff_per_wh: Optional[list[float]] = Field(
default=None,
validation_alias=AliasChoices("feed_in_tariff_per_wh", "einspeiseverguetung_euro_pro_wh"),
)
temperature_forecast: Optional[list[Optional[float]]] = None
class ConfigOptimizationRequest(
ConfigMixin, MeasurementMixin, PredictionMixin, GeneticParametersBaseModel
):
"""Supply observations while hardware, tariffs and scheduling remain in config."""
soc: dict[str, Annotated[int, Field(ge=0, le=100)]] = Field(default_factory=dict)
forecasts: RuntimeForecasts = Field(default_factory=RuntimeForecasts)
start_solution: Optional[list[float]] = None
start_solution_datetime: Optional[DateTime] = None
@field_validator("start_solution_datetime", mode="before")
@classmethod
def validate_start_solution_datetime(cls, value: Any) -> Optional[DateTime]:
"""Parse a previous-plan timestamp while retaining its explicit timezone."""
return GeneticOptimizationParameters.transform_start_solution_datetime(value)
async def resolve(self) -> GeneticOptimizationParameters:
"""Resolve inside the EMS lock after its slot start has been established."""
config = self.config
settings = config.optimization.genetic
config.validate_optimization_horizons()
ems = get_ems()
start = ems.start_datetime
observation_time = ems.observation_datetime
devices = config.devices
groups: dict[str, list[Any]] = {}
for name in ("batteries", "electric_vehicles", "inverters", "home_appliances"):
entries = list((getattr(devices, name) or {}).values())
maximum = getattr(devices, "max_" + name)
if maximum is not None and len(entries) > maximum:
raise ValueError(f"devices.{name} exceeds configured maximum {maximum}.")
if name != "home_appliances" and len(entries) > 1:
raise ValueError(f"GENETIC supports at most one device in devices.{name}.")
groups[name] = entries
ids = [device.device_id for entries in groups.values() for device in entries]
if len(ids) != len(set(ids)):
raise ValueError("Configured device IDs must be unique across device groups.")
storage = groups["batteries"] + groups["electric_vehicles"]
unknown = set(self.soc) - {device.device_id for device in storage}
if unknown:
raise ValueError(f"SoC supplied for unconfigured devices: {sorted(unknown)}.")
async def state(device: BatteriesCommonSettings) -> int:
if device.device_id in self.soc:
return self.soc[device.device_id]
try:
dates, values = await self.measurement.key_to_lists(
key=device.measurement_key_soc_factor,
start_datetime=observation_time.subtract(
seconds=settings.measurement_max_age_seconds
),
end_datetime=observation_time.add(seconds=1),
dropna=False,
)
samples = [
(date, value)
for date, value in zip(dates, values)
if date.timestamp() <= observation_time.timestamp()
]
date, value = max(samples, key=lambda sample: sample[0].timestamp())
if (
observation_time.timestamp() - date.timestamp()
> settings.measurement_max_age_seconds
or value is None
or not math.isfinite(value)
or not 0 <= value <= 1
):
raise ValueError("Invalid or stale SoC factor")
return int(value * 100)
except (ValueError, KeyError) as exc:
raise ValueError(
f"Fresh SoC missing for {device.device_id}; supply soc or a measurement."
) from exc
battery = None
if groups["batteries"]:
device = groups["batteries"][0]
battery = device.to_genetic_pv_bat_param()
battery.initial_soc_percentage = await state(device)
ev = None
if groups["electric_vehicles"]:
device = groups["electric_vehicles"][0]
ev = device.to_genetic_ev_bat_param()
ev.initial_soc_percentage = await state(device)
inverter = None
if groups["inverters"]:
device = groups["inverters"][0]
if device.battery_id != (battery.device_id if battery else None):
raise ValueError("Inverter battery_id must match the configured battery.")
inverter = device.to_genetic_param()
if inverter is None:
raise ValueError("Configure an inverter to model PV and grid energy flows.")
appliances = [device.to_genetic_param() for device in groups["home_appliances"]]
for device, appliance in zip(groups["home_appliances"], appliances):
key = device.cycles_completed_measurement_key or f"{device.device_id}.cycles_completed"
try:
dates, counts = await self.measurement.key_to_lists(
key=key,
start_datetime=observation_time.start_of("day"),
end_datetime=observation_time.add(seconds=1),
dropna=False,
)
except KeyError:
continue
samples = [
(date, count)
for date, count in zip(dates, counts)
if date.timestamp() <= observation_time.timestamp()
]
if not samples:
continue
count = max(samples, key=lambda item: item[0].timestamp())[1]
if (
count is None
or not math.isfinite(count)
or int(count) != count
or not 0 <= count <= appliance.num_cycles
):
raise ValueError(f"Invalid completed cycle count for {device.device_id}.")
appliance.completed_cycles = int(count)
origin = start.start_of("day")
if config.prediction.hours is None or config.prediction.hours <= 0:
raise ValueError("Configure a positive prediction horizon.")
end = start.add(hours=config.prediction.hours)
interval = to_duration(settings.interval_sec)
updated = False
async def forecast(
supplied: Optional[list[float]], key: str, energy: bool = False
) -> list[float]:
nonlocal updated
if supplied is not None:
return list(supplied)
if not updated:
await self.prediction.update_data()
updated = True
values = await bounded_forecast_array(
self.prediction, key=key, start_datetime=origin, end_datetime=end, interval=interval
)
if energy:
values = values * (settings.interval_sec / 3600)
return values.tolist()
supplied = self.forecasts
pv = await forecast(supplied.pv_forecast_wh, "pvforecast_ac_power", energy=True)
load = await forecast(supplied.total_load, "loadforecast_power_w", energy=True)
prices = await forecast(supplied.electricity_price_per_wh, "elecprice_marketprice_wh")
tariffs = await forecast(supplied.feed_in_tariff_per_wh, "feed_in_tariff_wh")
length = min(len(pv), len(load), len(prices), len(tariffs))
if not length:
raise ValueError("Forecast series must be nonempty.")
pv, load, prices, tariffs = (values[:length] for values in (pv, load, prices, tariffs))
first = int((start - origin).total_seconds() / settings.interval_sec)
last = first + settings.horizon
if settings.horizon <= 0 or len(pv) < last:
raise ValueError("Forecast does not cover the positive GENETIC control horizon.")
for key, values in (
("PV", pv),
("load", load),
("prices", prices),
("feed-in tariff", tariffs),
):
if not all(math.isfinite(value) for value in values[first:last]):
raise ValueError(f"Missing or invalid {key} within the control horizon.")
if any(value < 0 for values in (pv, load) for value in values[first:last]):
raise ValueError("PV and load energy must be nonnegative.")
previous = ems.genetic_solution()
warm_start = self.start_solution
warm_start_time = self.start_solution_datetime
if warm_start is None and previous is not None:
warm_start = previous.start_solution
warm_start_time = previous.start_solution_datetime
return GeneticOptimizationParameters(
forecast_interval_seconds=settings.interval_sec,
ems=GeneticEnergyManagementParameters(
pv_forecast_wh=pv,
total_load=load,
electricity_price_per_wh=prices,
feed_in_tariff_per_wh=tariffs,
price_per_wh_battery=settings.terminal_value_euro_per_kwh / 1000,
),
pv_battery=battery,
ev=ev,
inverter=inverter,
home_appliances=appliances,
temperature_forecast=(
(supplied.temperature_forecast[:length] + [None] * length)[:length]
if supplied.temperature_forecast is not None
else None
),
start_solution=warm_start,
start_solution_datetime=warm_start_time,
)
@@ -0,0 +1,61 @@
"""Read forecast interval averages without filling gaps or extrapolating the tail."""
from typing import Any
import numpy as np
import pandas as pd
from akkudoktoreos.utils.datetimeutil import DateTime, Duration
async def bounded_forecast_array(
prediction: Any,
*,
key: str,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
) -> np.ndarray:
"""Integrate stored interval averages only across completely covered target slots.
Source timestamps are interval starts. Infer their smallest positive cadence,
capped at one hour. A missing source interval, explicit NaN or the end of the
available forecast remains missing. UTC elapsed time handles DST transitions.
The returned values retain the input units; callers convert W to slot Wh once.
"""
seconds = int(interval.total_seconds())
start = start_datetime.timestamp()
end = end_datetime.timestamp()
if seconds <= 0 or end <= start or (end - start) % seconds:
raise ValueError("Forecast bounds must contain a positive whole number of slots.")
result = np.full(int((end - start) / seconds), np.nan)
try:
series = await prediction.key_to_raw_series(key=key, dropna=False)
except KeyError:
return result
if series.empty:
return result
series = pd.to_numeric(series, errors="coerce").sort_index()
series = series[~series.index.duplicated(keep="last")]
stamps = pd.to_datetime(series.index, utc=True).as_unit("ns").asi8 / 1e9
values = series.to_numpy(dtype=float)
differences = np.diff(stamps)
cadence = min(3600.0, float(np.min(differences))) if len(differences) else 3600.0
for slot in range(len(result)):
left = start + slot * seconds
right = left + seconds
position = max(0, int(np.searchsorted(stamps, left, side="right")) - 1)
covered = 0.0
weighted = 0.0
while position < len(stamps) and stamps[position] < right:
source_end = stamps[position] + cadence
if position + 1 < len(stamps):
source_end = min(source_end, stamps[position + 1])
overlap = max(0.0, min(right, source_end) - max(left, stamps[position]))
if overlap and np.isfinite(values[position]):
covered += overlap
weighted += (overlap / seconds) * values[position]
position += 1
if abs(covered - seconds) < 1e-6:
result[slot] = weighted
return result
File diff suppressed because it is too large Load Diff
@@ -8,9 +8,9 @@ It also provides a method to assemble these parameters from predictions,
forecasts, and fallback defaults, preparing them for optimization runs.
"""
from typing import Optional, Union
from datetime import datetime
from typing import Any, Optional, Union
import numpy as np
from loguru import logger
from pydantic import (
AliasChoices,
@@ -26,7 +26,6 @@ from akkudoktoreos.core.coreabc import (
ConfigMixin,
MeasurementMixin,
PredictionMixin,
get_ems,
)
from akkudoktoreos.devices.genetic.battery import (
ElectricVehicleParameters,
@@ -35,7 +34,7 @@ from akkudoktoreos.devices.genetic.battery import (
from akkudoktoreos.devices.genetic.homeappliance import HomeApplianceParameters
from akkudoktoreos.devices.genetic.inverter import InverterParameters
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
from akkudoktoreos.utils.datetimeutil import to_duration
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# Do not import directly from akkudoktoreos.core.coreabc
# EnergyManagementSystemMixin - Creates circular dependency with ems.py
@@ -50,7 +49,7 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel):
pv_forecast_wh: list[float] = Field(
validation_alias=AliasChoices("pv_forecast_wh", "pv_prognose_wh"),
json_schema_extra={
"description": "An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
"description": "An array of floats representing the forecasted photovoltaic energy in watt-hours per slot for different time intervals."
},
)
electricity_price_per_wh: list[float] = Field(
@@ -74,7 +73,7 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel):
total_load: list[float] = Field(
validation_alias=AliasChoices("total_load", "gesamtlast"),
json_schema_extra={
"description": "An array of floats representing the total load (consumption) in watts for different time intervals."
"description": "An array of floats representing the total load (consumption) in watt-hours per slot for different time intervals."
},
)
@@ -105,22 +104,13 @@ class GeneticEnergyManagementParameters(GeneticParametersBaseModel):
return self.total_load
@model_validator(mode="after")
def validate_list_length(self) -> Self:
"""Validate that all input lists are of the same length.
Raises:
ValueError: If input list lengths differ.
"""
pv_forecast_length = len(self.pv_forecast_wh)
if (
pv_forecast_length != len(self.electricity_price_per_wh)
or pv_forecast_length != len(self.total_load)
or (
isinstance(self.feed_in_tariff_per_wh, list)
and pv_forecast_length != len(self.feed_in_tariff_per_wh)
)
):
raise ValueError("Input lists have different lengths")
def validate_forecast_arrays(self) -> Self:
"""Require forecasts; the optimizer validates control coverage and clips the tail."""
arrays = [self.pv_forecast_wh, self.electricity_price_per_wh, self.total_load]
if isinstance(self.feed_in_tariff_per_wh, list):
arrays.append(self.feed_in_tariff_per_wh)
if any(not values for values in arrays):
raise ValueError("Forecast arrays must not be empty.")
return self
@@ -164,6 +154,91 @@ class GeneticOptimizationParameters(
},
)
forecast_interval_seconds: Optional[int] = Field(
default=None,
description="Input interval: 3600 for hourly, or optimization interval for native slots.",
)
home_appliances: Optional[list[HomeApplianceParameters]] = Field(
default=None,
json_schema_extra={
"description": "List of flexible consumers (home appliances) to schedule."
},
)
start_solution_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Start of the slot that gene 0 of 'start_solution' controls, as "
"returned with the previous solution. The warm start is shifted by "
"the slots that have elapsed until this run. Without it, a "
"'start_solution' identical to the last solution of this server "
"uses that solution's start; any other one is used unshifted."
),
"examples": [None, "2026-09-14T07:45:00+02:00"],
},
)
@field_validator("forecast_interval_seconds")
@classmethod
def validate_forecast_interval(cls, value: Optional[int]) -> Optional[int]:
if value not in (None, 900, 3600):
raise ValueError("forecast_interval_seconds must be 900 or 3600")
return value
@field_validator("start_solution_datetime", mode="before")
@classmethod
def transform_start_solution_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
# A stored run must retain its timezone/fold even when the server host
# uses another local timezone. ISO offsets survive JSON round trips.
aware = value
if isinstance(value, str):
try:
aware = datetime.fromisoformat(value)
except ValueError:
pass
if isinstance(aware, datetime) and aware.tzinfo is not None:
return DateTime.instance(aware)
return to_datetime(value)
@model_validator(mode="after")
def validate_home_appliances(self) -> Self:
"""Reject conflicting home appliance definitions.
The deprecated ``dishwasher`` field and the new ``home_appliances`` list
must not be set at the same time; nothing is silently overwritten.
Device ids within ``home_appliances`` must be unique.
"""
# Read the deprecated field via __dict__ to avoid emitting a deprecation
# warning on every internal validation.
dishwasher = self.__dict__.get("dishwasher")
if dishwasher is not None and self.home_appliances is not None:
raise ValueError(
"Provide either 'home_appliances' or the deprecated 'dishwasher', not both."
)
appliances = self.home_appliances or []
device_ids = [appliance.device_id for appliance in appliances]
if len(device_ids) != len(set(device_ids)):
raise ValueError("home_appliances device_id values must be unique.")
return self
def resolved_home_appliances(self) -> list[HomeApplianceParameters]:
"""Return the effective home appliance list.
Maps the deprecated single ``dishwasher`` onto a one-element list so the
optimizer only ever deals with the list form.
"""
if self.home_appliances is not None:
return list(self.home_appliances)
dishwasher = self.__dict__.get("dishwasher")
if dishwasher is not None:
return [dishwasher]
return []
# Computed fields for backward compatibility (deprecated German names)
@computed_field(json_schema_extra={"deprecated": True})
def pv_akku(self) -> Optional[SolarPanelBatteryParameters]:
@@ -208,620 +283,21 @@ class GeneticOptimizationParameters(
@classmethod
async def prepare(cls) -> "Optional[GeneticOptimizationParameters]":
"""Prepare optimization parameters from config, forecast and measurement data.
"""Resolve configured devices and fresh forecasts for automatic optimization.
Fills in values needed for optimization from available configuration, predictions and
measurements. If some data is missing, default or demo values are used.
Parameters start by definition of the genetic algorithm at hour 0 of the actual date
(not at start datetime of energy management run)
Returns:
GeneticOptimizationParameters: The fully prepared optimization parameters.
Raises:
ValueError: If required configuration values like start time are missing.
Missing inputs cancel the run without changing providers or inventing devices.
The same resolver serves the configuration-owned HTTP request.
"""
ems = get_ems()
from akkudoktoreos.optimization.genetic.configrequest import (
ConfigOptimizationRequest,
)
# The optimization paramters
oparams: "Optional[GeneticOptimizationParameters]" = None
# Check for run definitions
if ems.start_datetime is None:
error_msg = "Start datetime unknown."
logger.error(error_msg)
raise ValueError(error_msg)
# Check for general predictions conditions
if cls.config.general.latitude is None:
default_latitude = 52.52
logger.info(f"Latitude unknown - defaulting to {default_latitude}.")
cls.config.general.latitude = default_latitude
if cls.config.general.longitude is None:
default_longitude = 13.405
logger.info(f"Longitude unknown - defaulting to {default_longitude}.")
cls.config.general.longitude = default_longitude
if cls.config.prediction.hours is None:
logger.info("Prediction hours unknown - defaulting to 48 hours.")
cls.config.prediction.hours = 48
if cls.config.prediction.historic_hours is None:
logger.info("Prediction historic hours unknown - defaulting to 24 hours.")
cls.config.prediction.historic_hours = 24
# Check optimization definitions
if cls.config.optimization.genetic.horizon_hours is None:
logger.info("Optimization horizon unknown - defaulting to 24 hours.")
cls.config.optimization.genetic.horizon_hours = 24
if cls.config.optimization.genetic.interval_sec is None:
logger.info("Optimization interval unknown - defaulting to 3600 seconds.")
cls.config.optimization.genetic.interval_sec = 3600
if cls.config.optimization.genetic.interval_sec != 3600:
logger.info(
f"Optimization interval '{cls.config.optimization.genetic.interval_sec}' seconds "
"not supported - forced to 3600 seconds."
try:
return await ConfigOptimizationRequest().resolve()
except Exception as exc:
logger.error(
"Cannot prepare GENETIC parameters; canceling optimization with provider {}: {}",
cls.config.feedintariff.provider,
exc,
)
cls.config.optimization.genetic.interval_sec = 3600
# Check genetic algorithm definitions
if cls.config.optimization.genetic.individuals is None:
logger.info("Genetic individuals unknown - defaulting to 300.")
cls.config.optimization.genetic.individuals = 300
if cls.config.optimization.genetic.generations is None:
logger.info("Genetic generations unknown - defaulting to 400.")
cls.config.optimization.genetic.generations = 400
if "ev_soc_miss" not in cls.config.optimization.genetic.penalties:
logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.")
cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10
# Setup some basic providers if not set
if not cls.config.weather.provider:
cls.config.weather.provider = "OpenMeteo"
if not cls.config.load.provider:
cls.config.load.provider = "LoadAkkudoktor"
# Get start solution from last run
start_solution = None
last_solution = ems.genetic_solution()
if last_solution and last_solution.start_solution:
start_solution = last_solution.start_solution
# Add forecast and device data
interval = to_duration(cls.config.optimization.genetic.interval_sec)
power_to_energy_per_interval_factor = cls.config.optimization.genetic.interval_sec / 3600
parameter_start_datetime = ems.start_datetime.set(hour=0, second=0, microsecond=0)
parameter_end_datetime = parameter_start_datetime.add(hours=cls.config.prediction.hours)
max_retries = 10
for attempt in range(1, max_retries + 1):
# Collect all the data for optimisation, but do not exceed max retries
if attempt > max_retries:
error_msg = f"Maximum retries {max_retries} for parameter collection exceeded. Parameter preparation attempt {attempt}."
logger.error(error_msg)
raise ValueError(error_msg)
# Assure predictions are uptodate
await cls.prediction.update_data()
try: # Try weather first - predition is also needed by the default PV forecast
array = await cls.prediction.key_to_array(
key="weather_temp_air",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
weather_temp_air = array.tolist()
except Exception as e:
logger.info(
"No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.weather.provider = "OpenMeteo"
# Retry
continue
# Try electricity fees next - predition is also needed by the default electricity price
# If no provider is set the fees default to 0 anyway
if cls.config.elecfee.provider:
try:
array = await cls.prediction.key_to_array(
key="elecfee_consumption_amt_kwh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
except Exception as e:
logger.info(
"No electricity fee data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.21,
},
]
},
"consumption_percent_amt": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 19.0,
},
]
},
"feedin_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.0,
},
]
},
"feedin_percent_amt": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.0,
},
]
},
},
},
}
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="pvforecast_ac_power",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="linear",
)
pvforecast_ac_power = (array * power_to_energy_per_interval_factor).tolist()
except Exception as e:
logger.info(
"No PV forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"pvforecast": {
"provider": "PVForecastPVLib",
"max_planes": 4,
"planes": [
{
"surface_tilt": 7,
"surface_azimuth": 170,
"userhorizon": [20, 27, 22, 20],
"peakpower": 5.0,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 10000,
"modules_per_string": 12,
"strings_per_inverter": 1,
},
{
"surface_tilt": 7,
"surface_azimuth": 90,
"userhorizon": [30, 30, 30, 50],
"peakpower": 4.8,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 10000,
"modules_per_string": 12,
"strings_per_inverter": 1,
},
{
"surface_tilt": 60,
"surface_azimuth": 140,
"userhorizon": [60, 30, 0, 30],
"peakpower": 1.4,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 2000,
"modules_per_string": 5,
"strings_per_inverter": 1,
},
{
"surface_tilt": 45,
"surface_azimuth": 185,
"userhorizon": [45, 25, 30, 60],
"peakpower": 1.6,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 1400,
"modules_per_string": 4,
"strings_per_inverter": 1,
},
],
},
}
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
elecprice_marketprice_wh = array.tolist()
except Exception as e:
logger.info(
"No Electricity Marketprice forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"elecprice": {
"elecpricefixed": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"duration": "1 day",
"start_time": "00:00:00.000000",
"value": 0.288,
}
]
}
},
"provider": "ElecPriceFixed",
},
},
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="loadforecast_power_w",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
loadforecast_power_w = array.tolist()
except Exception as e:
logger.info(
"No Load forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"load": {
"provider": "LoadAkkudoktor",
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": "3000",
},
},
}
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="feed_in_tariff_wh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
# Prediction records already contain amount/Wh; only fixed-provider
# configuration is expressed in amount/kWh. Preserve signs and units.
if cls.config.feedintariff.provider == "FeedInTariffImport":
if array.ndim != 1 or len(array) != len(pvforecast_ac_power):
raise ValueError(
"Imported feed-in tariff length does not match the forecast horizon"
)
if not np.isfinite(array).all():
raise ValueError(
"Imported feed-in tariff contains missing or non-finite values"
)
feed_in_tariff_wh = array.tolist()
except Exception as e:
if cls.config.feedintariff.provider == "FeedInTariffImport":
# An external EMS supplies the resolved sale revenue. Replacing
# missing imports with demo or purchase prices changes the economics.
logger.error(
"Cannot prepare GENETIC parameters: FeedInTariffImport revenue is "
"unavailable or invalid; keeping the configured provider and "
"canceling optimization: {}",
e,
)
return None
logger.info(
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.078,
},
],
},
},
},
}
)
# Retry
continue
# Add device data
# Batteries
# ---------
if cls.config.devices.max_batteries is None:
logger.info("Number of battery devices not configured - defaulting to 1.")
cls.config.devices.max_batteries = 1
if cls.config.devices.max_batteries == 0:
battery_params = None
battery_lcos_kwh = 0
else:
if cls.config.devices.batteries is None:
logger.info("No battery device data available - defaulting to demo data.")
cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
try:
# Take first battery
battery_config = list(cls.config.devices.batteries.values())[0]
battery_params = battery_config.to_genetic_pv_bat_param()
except Exception as e:
logger.info(
"No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
# Retry
continue
# Levelized cost of ownership
if battery_config.levelized_cost_of_storage_amt_kwh is None:
logger.info(
"No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.",
attempt,
)
battery_config.levelized_cost_of_storage_amt_kwh = 0
battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
# Initial SOC
try:
initial_soc_factor = await cls.measurement.key_to_value(
key=battery_config.measurement_key_soc_factor,
target_datetime=ems.start_datetime,
time_window=to_duration(to_duration("48 hours")),
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
f"Invalid battery initial SoC factor {initial_soc_factor} - defaulting to 0.0."
)
initial_soc_factor = 0.0
# genetic parameter is 0..100 as int
initial_soc_percentage = int(initial_soc_factor * 100)
except Exception:
initial_soc_percentage = None
if initial_soc_percentage is None:
logger.info(
f"No battery device SoC data (measurement key = '{battery_config.measurement_key_soc_factor}') available - defaulting to 0."
)
initial_soc_percentage = 0
battery_params.initial_soc_percentage = initial_soc_percentage
# Electric Vehicles
# -----------------
if cls.config.devices.max_electric_vehicles is None:
logger.info("Number of electric_vehicle devices not configured - defaulting to 1.")
cls.config.devices.max_electric_vehicles = 1
if cls.config.devices.max_electric_vehicles == 0:
electric_vehicle_params = None
else:
if cls.config.devices.electric_vehicles is None:
logger.info(
"No electric vehicle device data available - defaulting to demo data."
)
cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = {
"ev1": {
"device_id": "ev1",
"capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70,
},
}
try:
# Take first electric_vehicle
electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0]
electric_vehicle_params = electric_vehicle_config.to_genetic_ev_bat_param()
except Exception as e:
logger.info(
"No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = {
"ev1": {
"device_id": "ev1",
"capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70,
},
}
# Retry
continue
# Initial SOC
try:
initial_soc_factor = await cls.measurement.key_to_value(
key=electric_vehicle_config.measurement_key_soc_factor,
target_datetime=ems.start_datetime,
time_window=to_duration(to_duration("48 hours")),
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
f"Invalid electric vehicle initial SoC factor {initial_soc_factor} - defaulting to 0.0."
)
initial_soc_factor = 0.0
# genetic parameter is 0..100 as int
initial_soc_percentage = int(initial_soc_factor * 100)
except Exception:
initial_soc_percentage = None
if initial_soc_percentage is None:
logger.info(
f"No electric vehicle device SoC data (measurement key = '{electric_vehicle_config.measurement_key_soc_factor}') available - defaulting to 0."
)
initial_soc_percentage = 0
electric_vehicle_params.initial_soc_percentage = initial_soc_percentage
# Inverters
# ---------
if cls.config.devices.max_inverters is None:
logger.info("Number of inverter devices not configured - defaulting to 1.")
cls.config.devices.max_inverters = 1
if cls.config.devices.max_inverters == 0:
inverter_params = None
else:
if cls.config.devices.inverters is None:
logger.info("No inverter device data available - defaulting to demo data.")
cls.config.devices.inverters = {
"inverter1": {
"device_id": "inverter1",
"max_power_w": 10000,
"battery_id": battery_config.device_id,
},
}
try:
# Take first inverter
inverter_config = list(cls.config.devices.inverters.values())[0]
inverter_params = inverter_config.to_genetic_param()
except Exception as e:
logger.info(
"No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.devices.inverters = {
"inverter1": {
"device_id": "inverter1",
"max_power_w": 10000,
"battery_id": battery_config.device_id,
},
}
# Retry
continue
# Home Appliances
# ---------------
if cls.config.devices.max_home_appliances is None:
logger.info("Number of home appliance devices not configured - defaulting to 1.")
cls.config.devices.max_home_appliances = 1
if cls.config.devices.max_home_appliances == 0:
home_appliance_params = None
else:
home_appliance_params = None
if cls.config.devices.home_appliances is None:
logger.info(
"No home appliance device data available - defaulting to demo data."
)
cls.config.devices.home_appliances = {
"dishwasher1": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3.0,
"cycle_time_windows": {
"windows": [
{
"start_time": "08:00",
"duration": "5 hours",
},
{
"start_time": "15:00",
"duration": "3 hours",
},
],
},
},
}
try:
# Take first appliance
home_appliance_config = list(cls.config.devices.home_appliances.values())[0]
home_appliance_params = home_appliance_config.to_genetic_param()
except Exception as e:
logger.info(
"No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.devices.home_appliances = {
"dishwasher1": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3.0,
"cycle_time_windows": None,
},
}
# Retry
continue
# We got all parameter data
try:
oparams = GeneticOptimizationParameters(
ems=GeneticEnergyManagementParameters(
pv_forecast_wh=pvforecast_ac_power,
electricity_price_per_wh=elecprice_marketprice_wh,
feed_in_tariff_per_wh=feed_in_tariff_wh,
total_load=loadforecast_power_w,
price_per_wh_battery=battery_lcos_kwh / 1000,
),
temperature_forecast=weather_temp_air,
pv_battery=battery_params,
ev=electric_vehicle_params,
inverter=inverter_params,
dishwasher=home_appliance_params,
start_solution=start_solution,
)
except Exception as e:
logger.info(
"Can not prepare optimization parameters - will retry. Parameter preparation attempt {}: {}",
attempt,
e,
)
oparams = None
# Retry
continue
# Parameters prepared
break
return oparams
return None
@@ -3,20 +3,69 @@
Kept in an extra module to avoid cyclic dependencies on package import.
"""
from typing import Optional, Union
from enum import StrEnum
from typing import Any, Literal, Optional, Union
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
def normalize_genetic_settings(value: Any) -> Any:
"""Preserve old feature settings in the current algorithm-specific namespace.
Explicit nested values win. The classic flat configuration without GENETIC
markers remains available to the existing GENETIC0 migration.
"""
if not isinstance(value, dict):
return value
feature_keys = (
"tail_horizon_hours",
"terminal_value_mode",
"terminal_value_euro_per_kwh",
"terminal_value_window_hours",
"measurement_max_age_seconds",
)
is_genetic = value.get("algorithm") == "GENETIC" or any(key in value for key in feature_keys)
if not is_genetic or not any(
key in value for key in (*feature_keys, "interval", "horizon_hours")
):
return value
result = dict(value)
nested = dict(result.get("genetic") or {})
for key in (*feature_keys, "interval", "horizon_hours"):
if key in result:
nested.setdefault("interval_sec" if key == "interval" else key, result.pop(key))
result["genetic"] = nested
return result
class TerminalValueMode(StrEnum):
"""How the energy left in the battery at the end of the horizon is valued.
Modes
-----
- AUTO:
Solve the deterministic forecast tail and apply a conservative
continuation proxy at its end. Tail values may decrease with SOC when
empty capacity is valuable. With a zero tail, use the proxy directly.
- FIXED:
Credit every stored kWh with the configured
``terminal_value_euro_per_kwh`` (or ``preis_euro_pro_wh_akku`` of the
request). The historical behaviour; a value of 0 makes the optimizer
empty the battery towards the end of the horizon.
"""
AUTO = "AUTO"
FIXED = "FIXED"
class GeneticCommonSettings(SettingsBaseModel):
"""GENETIC Optimization Algorithm Configuration."""
interval_sec: int = Field(
interval_sec: Literal[900, 3600] = Field(
default=3600,
ge=15 * 60,
le=60 * 60,
json_schema_extra={
"description": "The optimization interval [sec]. Defaults to 3600 seconds (1 hour)",
"examples": [60 * 60, 15 * 60],
@@ -59,6 +108,59 @@ class GeneticCommonSettings(SettingsBaseModel):
},
)
measurement_max_age_seconds: int = Field(
default=300,
gt=0,
description="Maximum age of SoC measurements for configuration-based optimization [s].",
)
tail_horizon_hours: int = Field(
default=48,
ge=0,
json_schema_extra={
"description": "Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable."
},
)
terminal_value_mode: TerminalValueMode = Field(
default=TerminalValueMode.AUTO,
json_schema_extra={
"description": (
"How to value the energy left in the battery at the end of the "
"control horizon. AUTO solves the forecast tail with an AUTO "
"continuation proxy at its end (or only the proxy if tail is zero); FIXED "
"uses 'terminal_value_euro_per_kwh'. Defaults to AUTO."
),
"examples": ["AUTO", "FIXED"],
},
)
terminal_value_euro_per_kwh: float = Field(
default=0.0,
json_schema_extra={
"description": (
"Value assigned to usable battery energy remaining at the end of the "
"optimization horizon [EUR/kWh]. This terminal value is independent "
"of the battery LCOS. Only used with terminal_value_mode = FIXED. "
"Defaults to 0 EUR/kWh."
),
"examples": [0.0, 0.20],
},
)
terminal_value_window_hours: int = Field(
default=24,
ge=1,
json_schema_extra={
"description": (
"Length of the trailing window at the effective tail end the AUTO continuation "
"curve is derived from [h]. One day covers a full load and PV "
"cycle. Defaults to 24 hours."
),
"examples": [24],
},
)
# --- Penalties (existing) -------------------------------------------------
penalties: dict[str, Union[float, int, str]] = Field(
@@ -1,6 +1,6 @@
"""Genetic algorithm optimisation solution."""
from typing import Any, Optional, Union
from typing import Any, Optional, Union, cast
import numpy as np
import pandas as pd
@@ -30,8 +30,9 @@ from akkudoktoreos.optimization.genetic.geneticdevices import GeneticParametersB
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueResult
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
from akkudoktoreos.utils.utils import NumpyEncoder
@@ -239,11 +240,48 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
"losses_per_hour",
"home_appliance_wh_per_hour",
"electricity_price",
"feed_in_tariff",
mode="before",
)
def convert_numpy(cls, field: Any) -> Any:
return NumpyEncoder.convert_numpy(field)[0]
home_appliance_energy_wh: dict[str, list[float]] = Field(
default_factory=dict,
json_schema_extra={
"description": (
"Per-device appliance energy in watt-hours per optimization slot, "
"keyed by device_id."
)
},
)
feed_in_tariff: list[float] = Field(
validation_alias=AliasChoices("feed_in_tariff", "Feed_in_tariff"),
default_factory=list,
json_schema_extra={
"description": "Used feed-in tariff in €/Wh per hour, including predictions"
},
)
home_appliance_running: dict[str, list[bool]] = Field(
default_factory=dict, description="Active run occupancy, including zero-power phases."
)
@field_validator("home_appliance_energy_wh", mode="before")
def convert_numpy_appliance_energy(cls, field: Any) -> Any:
if isinstance(field, dict):
return {
device_id: NumpyEncoder.convert_numpy(values)[0]
for device_id, values in field.items()
}
return field
@computed_field(json_schema_extra={"deprecated": True})
def Feed_in_tariff(self) -> Optional[list[float]]:
"""Deprecated: use feed_in_tariff."""
return self.feed_in_tariff
class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"""**Note**: The first value of "load_wh_per_hour", "grid_feed_in_wh_per_hour", and "grid_consumption_wh_per_hour", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged."""
@@ -253,55 +291,66 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
parameters: GeneticOptimizationParameters = Field(
json_schema_extra={"description": "Optimization parameters used to generate solution."}
)
ac_charge: list[float] = Field(
json_schema_extra={
"description": "Array with AC charging values as relative power (0.0-1.0), other values set to 0."
}
)
dc_charge: list[float] = Field(
json_schema_extra={
"description": "Array with DC charging values as relative power (0-1), other values set to 0."
}
)
discharge_allowed: list[int] = Field(
json_schema_extra={
"description": "Array with discharge values (1 for discharge, 0 otherwise)."
}
)
ev_charge_hours_float: Optional[list[float]] = Field(
validation_alias=AliasChoices("ev_charge_hours_float", "eautocharge_hours_float"),
json_schema_extra={
"description": "Array with EV charging values as relative power (0.0-1.0), or `null` if no EV is optimized."
},
)
result: GeneticSimulationResult
ev_obj: Optional[ElectricVehicleResult] = Field(
validation_alias=AliasChoices("ev_obj", "eauto_obj"),
json_schema_extra={"description": "Electric vehicle state after optimization."},
)
start_hour: int = Field(
default=0,
json_schema_extra={"description": "Start hour."},
)
start_solution: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": "An array of binary values (0 or 1) representing a possible starting solution for the simulation."
},
)
washingstart: Optional[int] = Field(
default=None,
json_schema_extra={
"description": "Can be `null` or contain an object representing the start of washing (if applicable)."
},
)
extra_data: Optional[dict[str, Union[list[int], list[float]]]] = Field(
default=None,
json_schema_extra={
"description": ("Dictionary of balance: TBD, losses: TBD, constraints: TBD.")
},
)
fitness_history: Optional[dict[str, Union[list[int], list[float]]]] = Field(
fitness_history: Optional[dict[str, Any]] = Field(
default=None,
json_schema_extra={
"description": (
@@ -313,12 +362,12 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
)
},
)
fixed_seed: Optional[int] = Field(
default=None,
json_schema_extra={"description": "Fixed seed."},
)
# Computed fields for backward compatibility (deprecated German names)
@computed_field(json_schema_extra={"deprecated": True})
def eautocharge_hours_float(self) -> Optional[list[float]]:
"""Deprecated: Use ev_charge_hours_float instead."""
@@ -333,6 +382,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"ac_charge",
"dc_charge",
"discharge_allowed",
"battery_grid_export_allowed",
"battery_grid_export_factor",
mode="before",
)
def convert_numpy(cls, field: Any) -> Any:
@@ -347,39 +398,131 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
return ElectricVehicleResult(**field.to_dict())
return field
controls_start_at_now: bool = Field(
default=False, description="Control arrays start at the run timestamp instead of midnight."
)
battery_grid_export_allowed: list[int] = Field(
default_factory=list,
json_schema_extra={
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
},
)
terminal_value: Optional[TerminalValueResult] = Field(
default=None,
json_schema_extra={
"description": (
"The terminal value applied to the energy left in the battery at "
"the end of the horizon, including the curve it was read from. "
"None when no battery is part of the optimization."
)
},
)
battery_grid_export_factor: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": (
"Array with the battery-to-grid export level per slot as factor "
"of the rated discharge power (0.0 for no export). Empty when "
"direct marketing is disabled; a solution without this array "
"exports at full power wherever "
"'battery_grid_export_allowed' is 1."
)
},
)
start_solution_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Start of the slot that gene 0 of 'start_solution' controls. Send it "
"back together with 'start_solution' so the next run can shift the "
"warm start by the slots that have elapsed since."
),
"examples": [None, "2026-09-14T07:45:00+02:00"],
},
)
appliance_starts: dict[str, list[DateTime]] = Field(
default_factory=dict,
json_schema_extra={
"description": (
"Scheduled run start times per appliance device_id as absolute local datetimes."
)
},
)
appliance_deadline_missed: dict[str, bool] = Field(
default_factory=dict,
json_schema_extra={
"description": (
"Per appliance device_id with a 'deadline_datetime': whether the "
"scheduled run misses that deadline (or was not scheduled at "
"all). Appliances without a deadline are not listed."
)
},
)
interval_seconds: int = Field(
default=3600, description="Duration of one result/control slot in seconds."
)
@field_validator("start_solution_datetime", mode="before")
@classmethod
def transform_start_solution_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
return GeneticOptimizationParameters.transform_start_solution_datetime(value)
def _battery_device_id(self) -> str:
"""Get battery device id."""
parameters = getattr(self, "parameters", None)
if parameters is not None and parameters.pv_battery is not None:
return parameters.pv_battery.device_id
try:
return list(self.config.devices.batteries.values())[0].device_id
return next(iter(self.config.devices.batteries.values())).device_id
except Exception:
return "battery1"
def _ev_device_id(self) -> str:
"""Get electric vehicle device id."""
parameters = getattr(self, "parameters", None)
if parameters is not None and parameters.ev is not None:
return parameters.ev.device_id
try:
return self.config.devices.electric_vehicles.values()[0].device_id
return next(iter(self.config.devices.electric_vehicles.values())).device_id
except Exception:
return "ev1"
def _homeappliance_device_id(self) -> str:
"""Get home appliance device id."""
parameters = getattr(self, "parameters", None)
if parameters is not None and parameters.resolved_home_appliances():
return parameters.resolved_home_appliances()[0].device_id
try:
return self.config.devices.home_appliances.values()[0].device_id
return next(iter(self.config.devices.home_appliances.values())).device_id
except Exception:
return "homeappliance1"
@staticmethod
def _battery_operation_from_solution(
self,
ac_charge: float,
dc_charge: float,
discharge_allowed: bool,
battery_grid_export_allowed: bool = False,
battery_grid_export_factor: float = 1.0,
) -> tuple[BatteryOperationMode, float]:
"""Maps low-level solution to a representative operation mode and factor.
Args:
ac_charge (float): Allowed AC-side charging power (relative units).
dc_charge (float): Allowed DC-side charging power (relative units).
discharge_allowed (bool): Whether discharging is permitted.
discharge_allowed (bool): Whether discharging to local load is permitted.
battery_grid_export_allowed (bool): Whether discharge into the grid is permitted.
battery_grid_export_factor (float): Export level as factor of the rated
discharge power ]0.0 ... 1.0]. Becomes the operation factor of
GRID_SUPPORT_EXPORT.
Returns:
tuple[BatteryOperationMode, float]: A tuple containing
@@ -387,15 +530,30 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- `float`: the operation factor corresponding to the active signal.
Notes:
- The mapping prioritizes AC charge > DC charge > discharge.
- Explicit grid export is separate from local-load discharge.
- The mapping prioritizes export > AC charge > DC charge > discharge.
- Multiple strategies can produce the same low-level signals; this function
returns a representative mode based on a defined priority order.
"""
# (0,0,0) → Nothing allowed
if ac_charge <= 0.0 and dc_charge <= 0.0 and not discharge_allowed:
if (
ac_charge <= 0.0
and dc_charge <= 0.0
and not discharge_allowed
and not battery_grid_export_allowed
):
return BatteryOperationMode.IDLE, 1.0
# (0,0,1) → Discharge only
if battery_grid_export_allowed:
if ac_charge > 0.0 or dc_charge > 0.0:
raise ValueError(
"Illegal state: battery_grid_export_allowed cannot be combined with charging"
)
return BatteryOperationMode.GRID_SUPPORT_EXPORT, min(
max(float(battery_grid_export_factor), 0.0), 1.0
)
# (0,0,1) -> Discharge for local load only
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
return BatteryOperationMode.PEAK_SHAVING, 1.0
@@ -436,7 +594,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
dc_charge: float,
discharge_allowed: bool,
soc_pct: float,
) -> tuple[float, float, bool]:
battery_grid_export_allowed: bool = False,
) -> tuple[float, float, bool, bool]:
"""Clamp raw genetic gene values by the battery's actual SOC at that hour.
The raw gene values represent the optimizer's *intent* and are stored
@@ -448,16 +607,24 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
Clamping rules:
- AC charge factor: scaled down proportionally when the battery
headroom (max_soc − current_soc) is smaller than what the
commanded factor would store in one hour. Set to 0 when full.
commanded factor would store in one optimization slot. Set to 0 when full.
- DC charge factor (PV): zeroed when battery is at or above max SOC
(the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC.
- Battery grid export: blocked when SOC is at or below min SOC.
"""
bat_dict = self.config.devices.batteries
if bat_dict is None or len(bat_dict) <= 0:
return ac_charge, dc_charge, discharge_allowed
parameters = getattr(self, "parameters", None)
bat_list = (
[parameters.pv_battery]
if parameters is not None and parameters.pv_battery is not None
else list((self.config.devices.batteries or {}).values())
if parameters is None
else []
)
if not bat_list:
return ac_charge, dc_charge, discharge_allowed, battery_grid_export_allowed
bat = list(bat_dict.values())[0]
bat = bat_list[0]
min_soc = float(bat.min_soc_percentage)
max_soc = float(bat.max_soc_percentage)
capacity_wh = float(bat.capacity_wh)
@@ -470,16 +637,27 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
if headroom_wh <= 0.0:
effective_ac = 0.0
else:
inv_list = self.config.devices.inverters
inv_list = (
[parameters.inverter]
if parameters is not None and parameters.inverter is not None
else list((self.config.devices.inverters or {}).values())
if parameters is None
else []
)
ac_to_dc_eff = float(inv_list[0].ac_to_dc_efficiency) if inv_list else 1.0
max_ac_cp_w = (
float(inv_list[0].max_ac_charge_power_w)
if inv_list and inv_list[0].max_ac_charge_power_w is not None
else float(bat.max_charge_power_w)
)
max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff
if max_dc_per_h_wh > headroom_wh:
effective_ac = effective_ac * (headroom_wh / max_dc_per_h_wh)
# Energy storable in one optimization slot, not per hour: scale the
# power [W] by the slot duration (1.0 hourly, 0.25 at 15 min).
slot_duration_h = float(self.interval_seconds) / 3600.0
max_dc_per_slot_wh = (
effective_ac * max_ac_cp_w * slot_duration_h * ac_to_dc_eff * ch_eff
)
if max_dc_per_slot_wh > headroom_wh:
effective_ac = effective_ac * (headroom_wh / max_dc_per_slot_wh)
# --- DC charge (PV): zero when battery is full ---
effective_dc = dc_charge
@@ -488,36 +666,49 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
# --- Discharge: block at min SOC ---
effective_dis = discharge_allowed and (soc_pct > min_soc)
effective_grid_export = battery_grid_export_allowed and (soc_pct > min_soc)
return effective_ac, effective_dc, effective_dis
return effective_ac, effective_dc, effective_dis, effective_grid_export
async def optimization_solution(self) -> OptimizationSolution:
"""Provide the genetic solution as a general optimization solution.
The battery modes are controlled by the grid control triggers:
- ac_charge: charge from grid
- discharge_allowed: discharge to grid
- discharge_allowed: discharge to local load
- battery_grid_export_allowed: discharge to grid
The following battery modes are supported:
- SELF_CONSUMPTION: ac_charge == 0 and discharge_allowed == 0
- GRID_SUPPORT_EXPORT: ac_charge == 0 and discharge_allowed == 1
- SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1
- PEAK_SHAVING: ac_charge == 0 and discharge_allowed == 1
- GRID_SUPPORT_EXPORT: battery_grid_export_allowed == 1
- GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
"""
start_datetime = get_ems().start_datetime
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour
interval_hours = 1
power_to_energy_per_interval_factor = 1.0
start_datetime = self.start_solution_datetime or get_ems().start_datetime
# New controls use the run-relative control horizon; old payloads retain a midnight prefix.
# entries indexed by slot (slot 0 == 00:00 local). Index this serializer
# by slot too. At the default interval of 3600 s slots_per_hour == 1 and
# this is the established hourly behaviour.
interval_s = self.interval_seconds
start_local = start_datetime.in_timezone(self.config.general.timezone)
start_day_slot = (
0
if self.controls_start_at_now
else int((start_local - start_local.start_of("day")).total_seconds() // interval_s)
)
# power [W] -> energy per slot [Wh]: multiply by the slot duration in hours.
power_to_energy_per_interval_factor = interval_s / 3600.0
# --- Create index based on list length and interval ---
# Ensure we only use the minimum of results and commands if differing
periods = min(len(self.result.costs_per_hour), len(self.ac_charge) - start_day_hour)
periods = min(len(self.result.costs_per_hour), len(self.ac_charge) - start_day_slot)
time_index = pd.date_range(
start=start_datetime,
periods=periods,
freq=f"{interval_hours}h",
freq=f"{interval_s}s",
)
n_points = len(time_index)
end_datetime = start_datetime.add(hours=n_points)
end_datetime = start_datetime.add(seconds=interval_s * n_points)
# Fill solution into dataframe with correct column names
# - load_energy_wh: Load of all energy consumers in wh"
@@ -533,7 +724,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
solution = pd.DataFrame(
{
"date_time": time_index,
# result starts at start_day_hour
# result starts at start_day_slot
"load_energy_wh": self.result.load_wh_per_hour[:n_points],
"grid_feedin_energy_wh": self.result.grid_feed_in_wh_per_hour[:n_points],
"grid_consumption_energy_wh": self.result.grid_consumption_wh_per_hour[:n_points],
@@ -544,68 +735,91 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
index=time_index,
)
# Add battery data
battery_device_id = self._battery_device_id()
solution[f"{battery_device_id}_soc_factor"] = [
v / 100
for v in self.result.battery_soc_per_hour[:n_points] # result starts at start_day_hour
]
operation: dict[str, list[float]] = {
"genetic_ac_charge_factor": [],
"genetic_dc_charge_factor": [],
"genetic_discharge_allowed_factor": [],
}
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
for hour_idx, rate in enumerate(self.ac_charge):
if hour_idx < start_day_hour:
continue
if hour_idx >= start_day_hour + n_points:
break
ac_charge_hour = self.ac_charge[hour_idx]
dc_charge_hour = self.dc_charge[hour_idx]
discharge_allowed_hour = bool(self.discharge_allowed[hour_idx])
if self.parameters.pv_battery is not None:
# Add battery data
battery_device_id = self._battery_device_id()
solution[f"{battery_device_id}_soc_factor"] = [
v / 100
for v in self.result.battery_soc_per_hour[
:n_points
] # result starts at start_day_slot
]
operation: dict[str, list[float]] = {
"genetic_ac_charge_factor": [],
"genetic_dc_charge_factor": [],
"genetic_discharge_allowed_factor": [],
"genetic_battery_grid_export_allowed_factor": [],
}
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
for hour_idx, rate in enumerate(self.ac_charge):
if hour_idx < start_day_slot:
continue
if hour_idx >= start_day_slot + n_points:
break
ac_charge_hour = self.ac_charge[hour_idx]
dc_charge_hour = self.dc_charge[hour_idx]
discharge_allowed_hour = bool(self.discharge_allowed[hour_idx])
battery_grid_export_allowed_hour = (
bool(self.battery_grid_export_allowed[hour_idx])
if hour_idx < len(self.battery_grid_export_allowed)
else False
)
# Solutions written before graded export carry no factor array; they
# exported at full power wherever the signal was set.
battery_grid_export_factor_hour = (
float(self.battery_grid_export_factor[hour_idx])
if hour_idx < len(self.battery_grid_export_factor)
else (1.0 if battery_grid_export_allowed_hour else 0.0)
)
# Raw genetic gene values — optimizer intent, stored verbatim
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
# Raw genetic gene values — optimizer intent, stored verbatim
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
operation["genetic_battery_grid_export_allowed_factor"].append(
battery_grid_export_factor_hour
)
# SOC-clamped effective values — what can physically be executed at
# this hour given the expected battery state of charge.
result_idx = hour_idx - start_day_hour
soc_h_pct = (
self.result.battery_soc_per_hour[result_idx]
if result_idx < len(self.result.battery_soc_per_hour)
else 0.0
)
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors(
ac_charge_hour, dc_charge_hour, discharge_allowed_hour, soc_h_pct
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac, eff_dc, eff_dis
)
for mode in BatteryOperationMode:
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
factor_key = f"{battery_device_id}_{mode.lower()}_op_factor"
if mode_key not in operation.keys():
operation[mode_key] = []
operation[factor_key] = []
if mode == operation_mode:
operation[mode_key].append(1.0)
operation[factor_key].append(operation_mode_factor)
else:
operation[mode_key].append(0.0)
operation[factor_key].append(0.0)
for key in operation.keys():
if len(operation[key]) != n_points:
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
logger.error(error_msg)
raise ValueError(error_msg)
solution[key] = operation[key]
# SOC-clamped effective values — what can physically be executed at
# this hour given the expected battery state of charge.
result_idx = hour_idx - start_day_slot
soc_h_pct = (
self.result.battery_soc_per_hour[result_idx]
if result_idx < len(self.result.battery_soc_per_hour)
else 0.0
)
eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors(
ac_charge_hour,
dc_charge_hour,
discharge_allowed_hour,
soc_h_pct,
battery_grid_export_allowed_hour,
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac, eff_dc, eff_dis, eff_grid_export, battery_grid_export_factor_hour
)
for mode in BatteryOperationMode:
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
factor_key = f"{battery_device_id}_{mode.lower()}_op_factor"
if mode_key not in operation.keys():
operation[mode_key] = []
operation[factor_key] = []
if mode == operation_mode:
operation[mode_key].append(1.0)
operation[factor_key].append(operation_mode_factor)
else:
operation[mode_key].append(0.0)
operation[factor_key].append(0.0)
for key in operation.keys():
if len(operation[key]) != n_points:
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
logger.error(error_msg)
raise ValueError(error_msg)
solution[key] = operation[key]
# Add EV battery solution
# ev_charge_hours_float start at hour 0 of start day
# result.ev_soc_per_hour start at start_datetime.hour
# eautocharge_hours_float start at hour 0 of start day
# result.EAuto_SoC_pro_Stunde start at start_datetime.hour
if self.ev_obj:
ev_device_id = self._ev_device_id()
if self.ev_charge_hours_float is None:
@@ -633,9 +847,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"genetic_ev_charge_factor": [],
}
for hour_idx, rate in enumerate(self.ev_charge_hours_float):
if hour_idx < start_day_hour:
if hour_idx < start_day_slot:
continue
if hour_idx >= start_day_hour + n_points:
if hour_idx >= start_day_slot + n_points:
break
operation["genetic_ev_charge_factor"].append(rate)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
@@ -660,32 +874,33 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
raise ValueError(error_msg)
solution[key] = operation[key]
# Add home appliance data
if self.config.devices.max_home_appliances and self.config.devices.max_home_appliances > 0:
# Use config and not self.washingstart as washingstart may be None (no start)
# even if configured to be started.
homeappliance_device_id = self._homeappliance_device_id()
# result starts at start_day_hour
solution[f"{homeappliance_device_id}_energy_wh"] = (
self.result.home_appliance_wh_per_hour[:n_points]
)
# Add home appliance data, one block of columns per device. Per-device
# energy arrays start at start_day_slot, like the other result arrays.
for device_id, energy_wh in self.result.home_appliance_energy_wh.items():
solution[f"{device_id}_energy_wh"] = energy_wh[:n_points]
operation = {
f"{homeappliance_device_id}_run_op_mode": [],
f"{homeappliance_device_id}_run_op_factor": [],
f"{homeappliance_device_id}_off_op_mode": [],
f"{homeappliance_device_id}_off_op_factor": [],
f"{device_id}_run_op_mode": [],
f"{device_id}_run_op_factor": [],
f"{device_id}_off_op_mode": [],
f"{device_id}_off_op_factor": [],
}
for hour_idx, energy in enumerate(solution[f"{homeappliance_device_id}_energy_wh"]):
if energy > 0.0:
operation[f"{homeappliance_device_id}_run_op_mode"].append(1.0)
operation[f"{homeappliance_device_id}_run_op_factor"].append(1.0)
operation[f"{homeappliance_device_id}_off_op_mode"].append(0.0)
operation[f"{homeappliance_device_id}_off_op_factor"].append(0.0)
running_slots = self.result.home_appliance_running.get(device_id, [])
for hour_idx, energy in enumerate(solution[f"{device_id}_energy_wh"]):
running = (
running_slots[hour_idx]
if hour_idx < len(running_slots)
else bool(energy and energy > 0.0)
)
if running:
operation[f"{device_id}_run_op_mode"].append(1.0)
operation[f"{device_id}_run_op_factor"].append(1.0)
operation[f"{device_id}_off_op_mode"].append(0.0)
operation[f"{device_id}_off_op_factor"].append(0.0)
else:
operation[f"{homeappliance_device_id}_run_op_mode"].append(0.0)
operation[f"{homeappliance_device_id}_run_op_factor"].append(0.0)
operation[f"{homeappliance_device_id}_off_op_mode"].append(1.0)
operation[f"{homeappliance_device_id}_off_op_factor"].append(1.0)
operation[f"{device_id}_run_op_mode"].append(0.0)
operation[f"{device_id}_run_op_factor"].append(0.0)
operation[f"{device_id}_off_op_mode"].append(1.0)
operation[f"{device_id}_off_op_factor"].append(1.0)
for key in operation.keys():
if len(operation[key]) != n_points:
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
@@ -706,18 +921,33 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
},
index=time_index,
)
pred = get_prediction()
if self.controls_start_at_now:
snapshot = self.parameters.ems
prediction["pvforecast_ac_energy_wh"] = snapshot.pv_forecast_wh[:n_points]
prediction["loadforecast_energy_wh"] = snapshot.total_load[:n_points]
prediction["elec_price_amt_kwh"] = (
np.asarray(snapshot.electricity_price_per_wh[:n_points]) * 1000
)
tariffs = snapshot.feed_in_tariff_per_wh
prediction["feed_in_tariff_amt_kwh"] = (
np.asarray(tariffs[:n_points]) * 1000
if isinstance(tariffs, list)
else tariffs * 1000
)
if self.controls_start_at_now and self.parameters.temperature_forecast is not None:
prediction["weather_air_temp_celcius"] = self.parameters.temperature_forecast[:n_points]
pred = None if self.controls_start_at_now else get_prediction()
prediction_specs: list[tuple[str, FillMethod, str, float]] = [
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
(
"pvforecast_ac_power",
"linear",
"ffill",
"pvforecast_ac_energy_wh",
power_to_energy_per_interval_factor,
),
(
"pvforecast_dc_power",
"linear",
"ffill",
"pvforecast_dc_energy_wh",
power_to_energy_per_interval_factor,
),
@@ -741,32 +971,32 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
),
(
"loadforecast_power_w",
"linear",
"ffill",
"loadforecast_energy_wh",
power_to_energy_per_interval_factor,
),
(
"loadakkudoktor_std_power_w",
"linear",
"ffill",
"loadakkudoktor_std_energy_wh",
power_to_energy_per_interval_factor,
),
(
"loadakkudoktor_mean_power_w",
"linear",
"ffill",
"loadakkudoktor_mean_energy_wh",
power_to_energy_per_interval_factor,
),
]
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in prediction_specs:
if pred_key in pred.record_keys:
]:
if pred_solution_key in prediction.columns:
continue
if pred is not None and pred_key in pred.record_keys:
array = await pred.key_to_array(
key=pred_key,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration(f"{interval_hours} hours"),
fill_method=pred_fill_method,
interval=to_duration(f"{interval_s} seconds"),
fill_method=cast(FillMethod, pred_fill_method),
)
# 'key_to_array()' creates None values array if no data records are available.
if array is not None and array.size > 0 and not np.any(pd.isna(array)):
@@ -777,7 +1007,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
generated_at=to_datetime(),
comment="Optimization solution derived from GeneticSolution.",
valid_from=start_datetime,
valid_until=start_datetime.add(hours=self.config.optimization.genetic.horizon_hours),
valid_until=end_datetime,
total_losses_energy_wh=self.result.total_losses,
total_revenues_amt=self.result.total_revenue,
total_costs_amt=self.result.total_costs,
@@ -792,8 +1022,16 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
def energy_management_plan(self) -> EnergyManagementPlan:
"""Provide the genetic solution as an energy management plan."""
start_datetime = get_ems().start_datetime
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour
start_datetime = self.start_solution_datetime or get_ems().start_datetime
# Index by slot, not hour (mirrors optimization_solution). At the default
# interval of 3600 s this reduces to the start hour-of-day.
interval_s = self.interval_seconds
start_local = start_datetime.in_timezone(self.config.general.timezone)
start_day_slot = (
0
if self.controls_start_at_now
else int((start_local - start_local.start_of("day")).total_seconds() // interval_s)
)
plan = EnergyManagementPlan(
id=f"plan-genetic@{to_datetime(as_string=True)}",
generated_at=to_datetime(),
@@ -801,55 +1039,70 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
comment="Energy management plan derived from GeneticSolution.",
)
# Add battery instructions (fill rate based control)
last_operation_mode: Optional[str] = None
last_operation_mode_factor: Optional[float] = None
resource_id = self._battery_device_id()
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
logger.debug("BAT: {} - {}", resource_id, self.ac_charge[start_day_hour:])
for hour_idx, rate in enumerate(self.ac_charge):
if hour_idx < start_day_hour:
continue
# Derive SOC-clamped effective factors so that FRBCInstruction
# operation_mode_factor reflects what can physically be executed,
# while the raw genetic gene values are preserved in the solution
# dataframe (genetic_*_factor columns).
result_idx = hour_idx - start_day_hour
soc_h_pct = (
self.result.battery_soc_per_hour[result_idx]
if result_idx < len(self.result.battery_soc_per_hour)
else 0.0
)
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors(
self.ac_charge[hour_idx],
self.dc_charge[hour_idx],
bool(self.discharge_allowed[hour_idx]),
soc_h_pct,
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac, eff_dc, eff_dis
)
if (
operation_mode == last_operation_mode
and operation_mode_factor == last_operation_mode_factor
):
# Skip, we already added the instruction
continue
last_operation_mode = operation_mode
last_operation_mode_factor = operation_mode_factor
execution_time = start_datetime.add(hours=hour_idx - start_day_hour)
plan.add_instruction(
FRBCInstruction(
resource_id=resource_id,
execution_time=execution_time,
actuator_id=resource_id,
operation_mode_id=operation_mode,
operation_mode_factor=operation_mode_factor,
if self.parameters.pv_battery is not None:
# Add battery instructions (fill rate based control)
last_operation_mode: Optional[str] = None
last_operation_mode_factor: Optional[float] = None
resource_id = self._battery_device_id()
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
logger.debug("BAT: {} - {}", resource_id, self.ac_charge[start_day_slot:])
for hour_idx, rate in enumerate(self.ac_charge):
if hour_idx < start_day_slot:
continue
# Derive SOC-clamped effective factors so that FRBCInstruction
# operation_mode_factor reflects what can physically be executed,
# while the raw genetic gene values are preserved in the solution
# dataframe (genetic_*_factor columns).
result_idx = hour_idx - start_day_slot
soc_h_pct = (
self.result.battery_soc_per_hour[result_idx]
if result_idx < len(self.result.battery_soc_per_hour)
else 0.0
)
battery_grid_export_allowed_hour = (
bool(self.battery_grid_export_allowed[hour_idx])
if hour_idx < len(self.battery_grid_export_allowed)
else False
)
eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors(
self.ac_charge[hour_idx],
self.dc_charge[hour_idx],
bool(self.discharge_allowed[hour_idx]),
soc_h_pct,
battery_grid_export_allowed_hour,
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac,
eff_dc,
eff_dis,
eff_grid_export,
self.battery_grid_export_factor[hour_idx]
if hour_idx < len(self.battery_grid_export_factor)
else 1.0,
)
if (
operation_mode == last_operation_mode
and operation_mode_factor == last_operation_mode_factor
):
# Skip, we already added the instruction
continue
last_operation_mode = operation_mode
last_operation_mode_factor = operation_mode_factor
execution_time = start_datetime.add(
seconds=interval_s * (hour_idx - start_day_slot)
)
plan.add_instruction(
FRBCInstruction(
resource_id=resource_id,
execution_time=execution_time,
actuator_id=resource_id,
operation_mode_id=operation_mode,
operation_mode_factor=operation_mode_factor,
)
)
)
# Add EV battery instructions (fill rate based control)
# ev_charge_hours_float start at hour 0 of start day
# eautocharge_hours_float start at hour 0 of start day
if self.ev_obj:
resource_id = self._ev_device_id()
if self.ev_charge_hours_float is None:
@@ -868,10 +1121,10 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
last_operation_mode = None
last_operation_mode_factor = None
logger.debug(
"EV: {} - {}", resource_id, self.ev_charge_hours_float[start_day_hour:]
"EV: {} - {}", resource_id, self.ev_charge_hours_float[start_day_slot:]
)
for hour_idx, rate in enumerate(self.ev_charge_hours_float):
if hour_idx < start_day_hour:
if hour_idx < start_day_slot:
continue
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
rate, 0.0, False
@@ -884,7 +1137,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
continue
last_operation_mode = operation_mode
last_operation_mode_factor = operation_mode_factor
execution_time = start_datetime.add(hours=hour_idx - start_day_hour)
execution_time = start_datetime.add(
seconds=interval_s * (hour_idx - start_day_slot)
)
plan.add_instruction(
FRBCInstruction(
resource_id=resource_id,
@@ -895,34 +1150,36 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
)
)
# Add home appliance instructions (demand driven based control)
if self.config.devices.max_home_appliances and self.config.devices.max_home_appliances > 0:
# Use config and not self.washingstart as washingstart may be None (no start)
# even if configured to be started.
resource_id = self._homeappliance_device_id()
last_energy: Optional[float] = None
for hours, energy in enumerate(self.result.home_appliance_wh_per_hour):
# Add home appliance instructions (demand driven based control), one
# stream of instructions per device. A new instruction is only emitted on
# a transition between OFF (energy == 0) and RUN (energy > 0); a mere
# power change within a running profile does not add an instruction.
for resource_id, energy_wh in self.result.home_appliance_energy_wh.items():
last_state: Optional[bool] = None
for hours, energy in enumerate(energy_wh):
# hours starts at start_datetime with 0
if energy is None:
raise ValueError(
f"Unexpected value {energy} in {self.result.home_appliance_wh_per_hour}"
f"Unexpected value {energy} in home_appliance_energy_wh[{resource_id}]"
)
if last_energy is None or energy != last_energy:
if energy > 0.0:
operation_mode = ApplianceOperationMode.RUN # type: ignore[assignment]
else:
operation_mode = ApplianceOperationMode.OFF # type: ignore[assignment]
operation_mode_factor = 1.0
execution_time = start_datetime.add(hours=hours)
running_slots = self.result.home_appliance_running.get(resource_id, [])
running = running_slots[hours] if hours < len(running_slots) else energy > 0.0
if last_state is None or running != last_state:
appliance_mode = (
ApplianceOperationMode.RUN if running else ApplianceOperationMode.OFF
)
execution_time = start_datetime.add(seconds=interval_s * hours)
plan.add_instruction(
DDBCInstruction(
resource_id=resource_id,
execution_time=execution_time,
actuator_id=resource_id,
operation_mode_id=operation_mode,
operation_mode_factor=operation_mode_factor,
operation_mode_id=appliance_mode,
operation_mode_factor=1.0,
)
)
last_energy = energy
last_state = running
if not plan.instructions:
plan.valid_from = start_datetime
return plan
@@ -27,7 +27,11 @@ class GeneticVisualizationReport(ConfigMixin):
def __init__(
self,
version: str = "0.0.1",
interval_seconds: int = 3600,
) -> None:
if interval_seconds <= 0:
raise ValueError("Report interval must be positive")
self.interval_seconds = interval_seconds
# Initialize the report with empty groups
self.groups: list[list[Callable[[], None]]] = [] # Store groups of charts
self.current_group: list[
@@ -129,12 +133,16 @@ class GeneticVisualizationReport(ConfigMixin):
line_styles: Optional[list[str]] = None,
x2label: Optional[Union[str, None]] = "Hours Since Start",
) -> None:
"""Create a line chart and add it to the current group."""
"""Plot interval values at their start timestamps without energy rescaling."""
if not y_list or not len(y_list[0]):
return
if any(len(values) != len(y_list[0]) for values in y_list):
raise ValueError("Date chart series must cover the same intervals")
def chart() -> None:
timestamps = [
start_date.add(hours=i) for i in range(len(y_list[0]))
] # 840 timestamps at 1-hour intervals
start_date.add(seconds=i * self.interval_seconds) for i in range(len(y_list[0]))
]
for idx, y_data in enumerate(y_list):
label = labels[idx] if labels else None # Chart label
@@ -150,18 +158,14 @@ class GeneticVisualizationReport(ConfigMixin):
# Format the time axis
plt.gca().xaxis.set_major_formatter(
mdates.DateFormatter("%Y-%m-%d", tz=self.config.general.timezone)
mdates.DateFormatter("%Y-%m-%d", tz=start_date.tzinfo)
) # Show date and time
plt.gca().xaxis.set_major_locator(
mdates.DayLocator(interval=1, tz=self.config.general.timezone)
mdates.DayLocator(interval=1, tz=start_date.tzinfo)
) # Major ticks every day
plt.gca().xaxis.set_minor_locator(
mdates.HourLocator(interval=2, tz=self.config.general.timezone)
)
plt.gca().xaxis.set_minor_locator(mdates.HourLocator(interval=2, tz=start_date.tzinfo))
# Minor ticks every 6 hours
plt.gca().xaxis.set_minor_formatter(
mdates.DateFormatter("%H", tz=self.config.general.timezone)
)
plt.gca().xaxis.set_minor_formatter(mdates.DateFormatter("%H", tz=start_date.tzinfo))
# plt.gcf().autofmt_xdate(rotation=45, which="major")
# Auto-format the x-axis for readability
@@ -203,10 +207,12 @@ class GeneticVisualizationReport(ConfigMixin):
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]])
# ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
ax2.set_xticks(
mdates.date2num(timestamps[:: len(timestamps) // 12])
mdates.date2num(timestamps[:: max(1, len(timestamps) // 12)])
) # Select 10 evenly spaced ticks
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 24]])
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
ax2.set_xticklabels(
[f"{h:g}" for h in hours_since_start[:: max(1, len(timestamps) // 12)]]
)
if x2label:
ax2.set_xlabel(x2label)
@@ -455,14 +461,18 @@ def genetic_prepare_visualize(
) -> bytes:
global debug_visualize
report = GeneticVisualizationReport()
next_full_hour_date = get_ems().start_datetime
start_hour = solution.start_hour
report = GeneticVisualizationReport(interval_seconds=solution.interval_seconds)
# New solutions own their timeline. The fallback supports historical hourly solutions.
start_datetime = solution.start_solution_datetime or get_ems().start_datetime
start_offset = 0 if solution.controls_start_at_now else solution.start_hour
control_slots = len(solution.result.load_wh_per_hour)
end_offset = start_offset + control_slots
# Forecasts can include a diagnostic tail; report executable intervals here.
# Group 1:
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
solution.parameters.ems.total_load[start_hour:],
solution.parameters.ems.total_load[start_offset:end_offset],
],
title="Load Profile",
# xlabel="Hours", # not enough space
@@ -470,9 +480,9 @@ def genetic_prepare_visualize(
labels=["Total Load (Wh)"],
)
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
solution.parameters.ems.pv_forecast_wh[start_hour:],
solution.parameters.ems.pv_forecast_wh[start_offset:end_offset],
],
title="PV Forecast",
# xlabel="Hours", # not enough space
@@ -480,14 +490,11 @@ def genetic_prepare_visualize(
)
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
np.full(
len(solution.parameters.ems.total_load) - start_hour,
solution.parameters.ems.feed_in_tariff_per_wh[start_hour:]
if isinstance(solution.parameters.ems.feed_in_tariff_per_wh, list)
else solution.parameters.ems.feed_in_tariff_per_wh,
)
np.asarray(solution.parameters.ems.feed_in_tariff_per_wh[start_offset:end_offset])
if isinstance(solution.parameters.ems.feed_in_tariff_per_wh, list)
else np.full(control_slots, solution.parameters.ems.feed_in_tariff_per_wh)
],
title="Remuneration",
# xlabel="Hours", # not enough space
@@ -496,9 +503,9 @@ def genetic_prepare_visualize(
)
if solution.parameters.temperature_forecast:
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
solution.parameters.temperature_forecast[start_hour:],
solution.parameters.temperature_forecast[start_offset:end_offset],
],
title="Temperature Forecast",
# xlabel="Hours", # not enough space
@@ -509,7 +516,7 @@ def genetic_prepare_visualize(
# Group 2:
report.create_line_chart_date(
start_date=next_full_hour_date, # start_date
start_date=start_datetime, # start_date
y_list=[
solution.result.load_wh_per_hour,
solution.result.home_appliance_wh_per_hour,
@@ -517,7 +524,7 @@ def genetic_prepare_visualize(
solution.result.grid_consumption_wh_per_hour,
solution.result.losses_per_hour,
],
title="Energy Flow per Hour",
title="Energy Flow per Interval",
# xlabel="Date", # not enough space
ylabel="Energy (Wh)",
labels=[
@@ -534,9 +541,9 @@ def genetic_prepare_visualize(
# Group 3:
report.create_line_chart_date(
start_date=next_full_hour_date,
start_date=start_datetime,
y_list=[solution.result.battery_soc_per_hour, solution.result.ev_soc_per_hour],
title="Battery SOC",
title="Battery SOC at Interval Start",
# xlabel="Date", # not enough space
ylabel="%",
labels=[
@@ -546,54 +553,60 @@ def genetic_prepare_visualize(
markers=["o", "x"],
)
report.create_line_chart_date(
start_date=next_full_hour_date, # start_date
y_list=[solution.parameters.ems.electricity_price_per_wh[start_hour:]],
start_date=start_datetime, # start_date
y_list=[solution.parameters.ems.electricity_price_per_wh[start_offset:end_offset]],
# title="Electricity Price", # not enough space
# xlabel="Date", # not enough space
ylabel="Electricity Price (amount/Wh)",
x2label=None, # not enough space
)
labels = list(
item
for sublist in zip(
list(str(i) for i in range(0, 23, 2)), list(str(" ") for i in range(0, 23, 2))
)
for item in sublist
)
labels = labels[start_hour:] + labels
report.create_bar_chart(
labels=labels,
values_list=[
solution.ac_charge[start_hour:],
solution.dc_charge[start_hour:],
solution.discharge_allowed[start_hour:],
],
title="AC/DC Charging and Discharge Overview",
ylabel="Relative Power (0-1) / Discharge (0 or 1)",
label_names=["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"],
colors=["blue", "green", "red"],
bottom=3,
xlabels=labels,
controls: list[Union[np.ndarray, list[Optional[float]], list[float]]] = [
solution.ac_charge[start_offset:end_offset],
solution.dc_charge[start_offset:end_offset],
np.asarray(solution.discharge_allowed[start_offset:end_offset]),
]
control_labels = ["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"]
if solution.battery_grid_export_allowed:
controls.append(np.asarray(solution.battery_grid_export_allowed[start_offset:end_offset]))
control_labels.append("Battery Grid Export Allowed")
if solution.battery_grid_export_factor:
controls.append(solution.battery_grid_export_factor[start_offset:end_offset])
control_labels.append("Battery Grid Export (relative)")
report.create_line_chart_date(
start_datetime,
controls,
title="Executable Battery Controls",
ylabel="Relative Power / Allowed (0-1)",
labels=control_labels,
)
report.finalize_group()
# Group 4:
report.create_line_chart_date(
next_full_hour_date, # start_date
start_datetime, # start_date
[
solution.result.costs_per_hour,
solution.result.revenue_per_hour,
],
title="Financial Balance per Hour",
title="Financial Balance per Interval",
# xlabel="Date", # not enough space
ylabel="Amount",
labels=["Costs", "Revenue"],
)
extra_data = solution.extra_data
if extra_data:
# Historical native runs used German diagnostic keys. Prefer current public keys.
extra_data = {
key: extra_data.get(key, extra_data.get(alias, []))
for key, alias in (
("losses", "verluste"),
("balance", "bilanz"),
("constraints", "nebenbedingung"),
)
}
if extra_data:
report.create_scatter_plot(
x=np.array(extra_data["losses"]),
@@ -683,6 +696,8 @@ def genetic_prepare_visualize(
)
report.finalize_group()
_add_solution_diagnostics(report, solution, start_datetime)
# Generate the PDF report
pdf = report.generate_pdf()
@@ -690,6 +705,119 @@ def genetic_prepare_visualize(
return pdf
def _add_solution_diagnostics(
report: GeneticVisualizationReport, solution: GeneticSolution, start: DateTime
) -> None:
"""Render the retained run diagnostics; tail actions are never executable controls."""
report.add_text_page(
f"Run start: {start.isoformat()}. Interval: {report.interval_seconds} seconds. "
"Energy values are Wh per interval; prices are amount/Wh. "
"Timeline positions mark interval starts. Battery SOC shows the beginning of each interval.",
title="GENETIC Run",
)
report.finalize_group()
device_ids = sorted(
solution.result.home_appliance_energy_wh.keys()
| solution.appliance_starts.keys()
| solution.appliance_deadline_missed.keys()
)
for device_id in device_ids:
energy = solution.result.home_appliance_energy_wh.get(device_id, [])
report.create_line_chart_date(
start,
[energy],
title=f"Flexible Consumer: {device_id}",
ylabel="Energy (Wh per interval)",
)
if energy:
report.finalize_group()
starts = ", ".join(
value.isoformat() for value in solution.appliance_starts.get(device_id, [])
)
report.add_text_page(
f"Scheduled starts: {starts or 'none'}. "
f"Deadline missed: {solution.appliance_deadline_missed.get(device_id, False)}.",
title=f"Flexible Consumer Schedule: {device_id}",
)
report.finalize_group()
terminal = solution.terminal_value
if terminal is None:
return
report.add_text_page(
f"Mode: {terminal.mode}. Reason: {terminal.reason or 'none'}. "
f"Control horizon: {terminal.control_horizon_hours:g} h. "
f"Requested tail: {terminal.requested_tail_hours:g} h; "
f"effective tail: {terminal.effective_tail_hours:g} h; "
f"tail end: {terminal.tail_end_hour:g} h after run start. "
f"Battery usable AC energy at control end: {terminal.battery_energy_wh:g} Wh. "
f"Credited terminal value: {terminal.credited_euro:g} EUR. "
f"Tail operating value: {terminal.tail_operating_euro:g} EUR. "
f"Continuation value: {terminal.continuation_value_euro:g} EUR "
f"({terminal.continuation_mode}). "
"Tail results are diagnostic lookahead only, not executable controls. "
"The terminal credit is a modeled residual value, not realized revenue.",
title="Terminal Value and Tail Diagnostics",
)
report.finalize_group()
if terminal.tail_diagnostics is not None:
report.add_json_page(
terminal.tail_diagnostics.model_dump(), title="Tail Forecast Summary", fontsize=10
)
report.finalize_group()
curve = terminal.curve
if curve is not None and curve.energy_wh:
def value_chart() -> None:
plt.plot(curve.energy_wh, curve.value_euro, label="Terminal credit")
plt.scatter(
[terminal.battery_energy_wh], [terminal.credited_euro], label="Selected state"
)
plt.xlabel("Usable AC Battery Energy (Wh)")
plt.ylabel("Terminal Value (EUR)")
plt.title("Residual Battery Value Curve")
plt.legend()
plt.grid(True)
report.add_chart_to_group(value_chart, "Residual Battery Value Curve")
report.finalize_group()
if terminal.tail_plan:
# Use explicit hour offsets, which also preserve a clipped tail's true start.
plan = terminal.tail_plan
def tail_chart() -> None:
dates = [start.add(seconds=slot.hour_from_start * 3600) for slot in plan]
for field, label in (
("grid_import_wh", "Grid import"),
("grid_export_wh", "Grid export"),
("battery_charge_wh", "Battery charge"),
("battery_discharge_wh", "Battery discharge"),
):
plt.plot(
mdates.date2num(dates),
[getattr(slot, field) for slot in plan],
label=label,
marker="o" if len(plan) == 1 else None,
)
if len(plan) == 1:
# A lone sample otherwise disappears and datetime autoscaling spans years.
half_interval = report.interval_seconds / 2
plt.xlim(
mdates.date2num(dates[0].subtract(seconds=half_interval)),
mdates.date2num(dates[0].add(seconds=half_interval)),
)
plt.gca().xaxis.set_major_formatter(
mdates.DateFormatter("%m-%d %H:%M", tz=start.tzinfo)
)
plt.xlabel("Interval Start (diagnostic only)")
plt.ylabel("Energy (Wh per interval)")
plt.title("Tail Lookahead: Not Executable Controls")
plt.legend()
plt.grid(True)
report.add_chart_to_group(tail_chart, "Tail Lookahead")
report.finalize_group()
def genetic_generate_example_report(filename: str = "example_report.pdf") -> None:
"""Generate example visualization report."""
global debug_visualize
@@ -0,0 +1,305 @@
"""Chronological, run-local battery lookahead using the production device physics.
The Bellman recursion interpolates continuation values on a stored-energy grid.
Tail actions never enter the executable control arrays. The selected path may
be returned separately as diagnostics so users can inspect the lookahead.
"""
import numpy as np
from pydantic import PrivateAttr
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.inverter import Inverter
from akkudoktoreos.optimization.genetic.terminalvalue import (
TailPlanSlot,
TerminalValueCurve,
)
TailAction = tuple[int, int, float, float]
def _action_name(action: TailAction) -> str:
dc, discharge, ac_rate, export_rate = action
if export_rate > 0:
return "BATTERY_EXPORT"
if ac_rate > 0:
return "GRID_CHARGE"
if dc and discharge:
return "SELF_CONSUMPTION"
if dc:
return "PV_CHARGE_ONLY"
if discharge:
return "DISCHARGE_ONLY"
return "HOLD"
def _simulate_action(
*,
bat: Battery,
inv: Inverter,
energy_wh: float,
action: TailAction,
price: float,
load: float,
pv: float,
tariff: float,
direct_marketing: bool,
) -> dict[str, float]:
"""Apply one tail action from one stored-energy state."""
dc, discharge, ac_rate, export = action
bat.soc_wh = float(energy_wh)
bat._charged_raw_wh_per_slot.fill(0)
bat._discharged_raw_wh_per_slot.fill(0)
ac_enabled = inv.ac_to_dc_efficiency > 0 and (
inv.max_ac_charge_power_w is None or inv.max_ac_charge_power_w > 0
)
bat.charge_array[0] = ac_rate if ac_rate > 0 and ac_enabled else dc
bat.discharge_array[0] = discharge if export == 0 or tariff > 0 else 0
sold, bought, losses, _ = inv.process_energy(
pv,
load,
0,
allow_battery_grid_export=direct_marketing and export > 0 and tariff > 0,
battery_grid_export_factor=export,
)
ac_grid_charge_wh = 0.0
if ac_rate > 0 and inv.ac_to_dc_efficiency > 0:
rate = ac_rate
if inv.max_ac_charge_power_w is not None and bat.max_charge_power_w > 0:
rate = min(
rate,
inv.max_ac_charge_power_w * inv.ac_to_dc_efficiency / bat.max_charge_power_w,
)
bat.charge_array[0] = rate
if rate > 0:
stored, loss = bat.charge_energy(None, 0, charge_factor=rate)
ac_grid_charge_wh = (stored + loss) / inv.ac_to_dc_efficiency
bought += ac_grid_charge_wh
losses += loss + max(ac_grid_charge_wh - stored - loss, 0.0)
if direct_marketing and tariff < 0:
sold = 0.0
discharged_wh = bat.discharged_energy_wh(0)
reward = (
sold * tariff - bought * price - (discharged_wh * bat.levelized_cost_of_storage_kwh / 1000)
)
return {
"next_state_wh": bat.soc_wh,
"reward_euro": reward,
"grid_export_wh": sold,
"grid_import_wh": bought,
"battery_charge_wh": (bat._charged_raw_wh_per_slot[0] * bat.charging_efficiency),
"battery_discharge_wh": discharged_wh,
"losses_wh": losses,
"ac_grid_charge_wh": ac_grid_charge_wh,
}
class TailValueCurve(TerminalValueCurve):
"""Value of usable AC battery energy, including the value of empty capacity.
Neither values nor marginal values are constrained to be monotone. The empty
state can earn money by charging at negative prices and selling later.
"""
_trace_context: dict = PrivateAttr(default_factory=dict)
def value(self, energy_wh: float) -> float:
return (
float(np.interp(energy_wh, self.energy_wh, self.value_euro)) if self.energy_wh else 0.0
)
def component_values(self, energy_wh: float) -> tuple[float, float]:
"""Return tail operating cash flow and continuation credit separately."""
if not self.energy_wh:
return 0.0, 0.0
operating = float(np.interp(energy_wh, self.energy_wh, self.operating_value_euro))
continuation = float(np.interp(energy_wh, self.energy_wh, self.continuation_value_euro))
return operating, continuation
def diagnostic_plan(self, energy_wh: float, control_horizon_hours: float) -> list[TailPlanSlot]:
"""Replay the optimal tail path for one control-end battery state."""
context = self._trace_context
if not context:
return []
bat = Battery(
context["battery_parameters"],
prediction_hours=1,
slot_duration_h=context["slot_duration_h"],
)
inv = Inverter(
context["inverter_parameters"],
battery=bat,
slot_duration_h=context["slot_duration_h"],
)
conversion = bat.discharging_efficiency * inv.dc_to_ac_efficiency
state_wh = bat.min_soc_wh + (energy_wh / conversion if conversion > 0 else 0.0)
state_wh = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh))
plan: list[TailPlanSlot] = []
arrays = zip(
context["prices"],
context["load"],
context["pv"],
context["tariffs"],
)
for slot, (price, load, pv, tariff) in enumerate(arrays):
candidates: list[tuple[float, TailAction, dict[str, float], float]] = []
for action in context["actions"]:
result = _simulate_action(
bat=bat,
inv=inv,
energy_wh=state_wh,
action=action,
price=price,
load=load,
pv=pv,
tariff=tariff,
direct_marketing=context["direct_marketing"],
)
remaining = float(
np.interp(
result["next_state_wh"],
context["states"],
context["future_values"][slot + 1],
)
)
candidates.append((result["reward_euro"] + remaining, action, result, remaining))
candidates.sort(key=lambda candidate: candidate[0], reverse=True)
chosen_value, chosen_action, chosen_result, remaining = candidates[0]
alternative = next(
(
candidate
for candidate in candidates[1:]
if _action_name(candidate[1]) != _action_name(chosen_action)
),
candidates[1] if len(candidates) > 1 else candidates[0],
)
dc, discharge, ac_rate, export_rate = chosen_action
start_soc = state_wh / bat.capacity_wh * 100
state_wh = chosen_result["next_state_wh"]
plan.append(
TailPlanSlot(
slot=slot,
hour_from_start=control_horizon_hours + slot * context["slot_duration_h"],
action=_action_name(chosen_action),
alternative_action=_action_name(alternative[1]),
decision_margin_euro=max(chosen_value - alternative[0], 0.0),
soc_start_percentage=start_soc,
soc_end_percentage=state_wh / bat.capacity_wh * 100,
pv_wh=pv,
load_wh=load,
grid_import_wh=chosen_result["grid_import_wh"],
grid_export_wh=chosen_result["grid_export_wh"],
battery_charge_wh=chosen_result["battery_charge_wh"],
battery_discharge_wh=chosen_result["battery_discharge_wh"],
import_price_euro_per_kwh=price * 1000,
feed_in_tariff_euro_per_kwh=tariff * 1000,
slot_value_euro=chosen_result["reward_euro"],
remaining_value_euro=remaining,
ac_charge_factor=ac_rate,
dc_charge_allowed=dc,
discharge_allowed=discharge,
battery_grid_export_factor=export_rate,
)
)
return plan
def build_tail_value_curve(
*,
battery: Battery,
inverter: Inverter,
prices_euro_per_wh: np.ndarray,
load_wh: np.ndarray,
pv_wh: np.ndarray,
feed_in_euro_per_wh: np.ndarray,
continuation: TerminalValueCurve,
charge_rates: list[float],
export_rates: list[float],
direct_marketing: bool,
grid_points: int = 101,
) -> TailValueCurve:
"""Solve the finite tail once, backwards in time, without mutating run devices.
LCOS uses delivered DC energy, exactly as in GeneticSimulation. State grid
endpoints include battery minimum and maximum SOC. Continuous next states are
interpolated rather than rounded (which would invent or destroy energy).
"""
arrays = [
np.asarray(a, dtype=float)
for a in (prices_euro_per_wh, load_wh, pv_wh, feed_in_euro_per_wh)
]
if len({len(a) for a in arrays}) != 1 or any(not np.isfinite(a).all() for a in arrays):
raise ValueError("Tail forecasts must have equal lengths and contain only finite values")
bat = Battery(battery.parameters, prediction_hours=1, slot_duration_h=battery.slot_duration_h)
bat.charge_array = np.zeros(1, dtype=float)
inv = Inverter(inverter.parameters, battery=bat, slot_duration_h=battery.slot_duration_h)
states = np.linspace(bat.min_soc_wh, bat.max_soc_wh, grid_points)
usable = (states - bat.min_soc_wh) * bat.discharging_efficiency * inv.dc_to_ac_efficiency
continuation_values = np.array([continuation.value(e) for e in usable])
operating_values = np.zeros(len(states))
values = continuation_values.copy()
# (DC charge, local discharge, AC rate, export rate). Preserve production
# modes; direct marketing permits disabling DC charge to make headroom.
actions: list[TailAction] = [(1, 0, 0.0, 0.0), (1, 1, 0.0, 0.0)]
actions += [(1, 0, rate, 0.0) for rate in charge_rates if rate > 0]
if direct_marketing:
actions += [(0, 0, 0.0, 0.0), (0, 1, 0.0, 0.0)]
actions += [(0, 0, rate, 0.0) for rate in charge_rates if rate > 0]
actions += [(dc, 1, 0.0, rate) for dc in (0, 1) for rate in export_rates if rate > 0]
future_values: list[np.ndarray] = [np.empty(0)] * (len(arrays[0]) + 1)
future_values[-1] = continuation_values.copy()
for slot in reversed(range(len(arrays[0]))):
price, load, pv, tariff = (array[slot] for array in arrays)
best = np.full(len(states), -np.inf)
best_operating = np.zeros(len(states))
best_continuation = np.zeros(len(states))
for action in actions:
next_states = np.empty(len(states))
rewards = np.empty(len(states))
for i, energy in enumerate(states):
action_result = _simulate_action(
bat=bat,
inv=inv,
energy_wh=energy,
action=action,
price=price,
load=load,
pv=pv,
tariff=tariff,
direct_marketing=direct_marketing,
)
rewards[i] = action_result["reward_euro"]
next_states[i] = action_result["next_state_wh"]
candidate_operating = rewards + np.interp(next_states, states, operating_values)
candidate_continuation = np.interp(next_states, states, continuation_values)
candidate = candidate_operating + candidate_continuation
better = candidate > best
best[better] = candidate[better]
best_operating[better] = candidate_operating[better]
best_continuation[better] = candidate_continuation[better]
values = best
operating_values = best_operating
continuation_values = best_continuation
future_values[slot] = best.copy()
result = TailValueCurve(
energy_wh=usable.tolist(),
value_euro=values.tolist(),
operating_value_euro=operating_values.tolist(),
continuation_value_euro=continuation_values.tolist(),
marginal_euro_per_kwh=(np.diff(values) / np.maximum(np.diff(usable), 1e-9) * 1000).tolist(),
window_slots=len(arrays[0]),
)
result._trace_context = {
"battery_parameters": battery.parameters,
"inverter_parameters": inverter.parameters,
"slot_duration_h": battery.slot_duration_h,
"states": states,
"future_values": future_values,
"actions": actions,
"prices": arrays[0],
"load": arrays[1],
"pv": arrays[2],
"tariffs": arrays[3],
"direct_marketing": direct_marketing,
}
return result
@@ -0,0 +1,359 @@
"""Terminal value of the energy left in the battery at the end of the horizon.
The optimizer stops at the horizon, but the energy still stored in the battery
keeps its worth: it replaces grid imports that would otherwise be paid for
afterwards. Crediting that worth with a single price per kWh - the historical
``preis_euro_pro_wh_akku`` - cannot describe it, because the value of stored
energy is **not linear in the amount stored**:
- The first kWh replaces the most expensive hour after the horizon.
- The next one replaces the second most expensive hour, and so on.
- Once every hour that PV cannot cover is served, further energy replaces
nothing; it is worth an export at best, and nothing at worst.
The resulting value function is monotone and concave. A scalar has to pick one
slope: high enough for the first kWh means hoarding a full battery, low enough
for the last kWh means running it empty by midnight. This module builds the
curve instead.
There is no forecast beyond the horizon, so the trailing window of the horizon
itself stands in for the day that follows: same season, same household rhythm,
same tariff structure. That approximation is the reason the curve is a planning
aid, not a prediction - which is also why the marginal values are deliberately
conservative wherever a choice exists.
"""
from typing import Optional
import numpy as np
from loguru import logger
from pydantic import Field
from akkudoktoreos.core.pydantic import PydanticBaseModel
class TerminalValueCurve(PydanticBaseModel):
"""Piecewise linear, concave value of battery energy left at the horizon.
``energy_wh`` and ``value_euro`` are the breakpoints of the cumulative
value, ``marginal_euro_per_kwh`` the slope of each segment. Both arrays
start at the origin; the curve is flat beyond its last breakpoint.
"""
energy_wh: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": "Breakpoints of usable AC energy left in the battery [Wh]."
},
)
value_euro: list[float] = Field(
default_factory=list,
json_schema_extra={"description": "Cumulative credit at each breakpoint [EUR]."},
)
operating_value_euro: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": "Tail operating component at each breakpoint [EUR]; empty for a proxy curve."
},
)
continuation_value_euro: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": "Continuation component at each breakpoint [EUR]; empty for a proxy curve."
},
)
marginal_euro_per_kwh: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": (
"Marginal value of the segment that starts at each breakpoint "
"[EUR/kWh]. May be negative or non-monotone in TAIL mode."
)
},
)
residual_energy_wh: float = Field(
default=0.0,
json_schema_extra={
"description": (
"Energy up to which the curve is backed by residual load - the "
"knee. Everything beyond it is only worth an export."
)
},
)
window_slots: int = Field(
default=0,
json_schema_extra={
"description": (
"Number of trailing horizon slots the curve was derived from. "
"Fewer slots than a full day mean a shorter proxy period."
)
},
)
def value(self, energy_wh: float) -> float:
"""Return the credit for ``energy_wh`` of usable AC energy [EUR].
Args:
energy_wh: Usable AC energy left in the battery.
Returns:
Interpolated value of the curve; 0.0 for an empty curve.
"""
if not self.energy_wh or energy_wh <= 0.0:
return 0.0
return float(np.interp(energy_wh, self.energy_wh, self.value_euro))
class TailDiagnostics(PydanticBaseModel):
"""Forecast summary used by the deterministic tail optimization."""
slots: int = 0
slot_hours: float = 0.0
soc_grid_points: int = 0
min_import_price_euro_per_kwh: float = 0.0
max_import_price_euro_per_kwh: float = 0.0
min_feed_in_tariff_euro_per_kwh: float = 0.0
max_feed_in_tariff_euro_per_kwh: float = 0.0
negative_import_price_slots: int = 0
positive_battery_export_slots: int = 0
class TailPlanSlot(PydanticBaseModel):
"""One diagnostic slot of the optimal tail path.
These values explain the lookahead used for fitness. They are diagnostics
only and are never copied into the executable control arrays.
"""
slot: int
hour_from_start: float
action: str
alternative_action: str = ""
decision_margin_euro: float = 0.0
soc_start_percentage: float
soc_end_percentage: float
pv_wh: float
load_wh: float
grid_import_wh: float
grid_export_wh: float
battery_charge_wh: float
battery_discharge_wh: float
import_price_euro_per_kwh: float
feed_in_tariff_euro_per_kwh: float
slot_value_euro: float
remaining_value_euro: float
ac_charge_factor: float
dc_charge_allowed: int
discharge_allowed: int
battery_grid_export_factor: float
class TerminalValueResult(PydanticBaseModel):
"""What the optimizer credited for the energy left in the battery."""
control_horizon_hours: float = 0
requested_tail_hours: float = 0
effective_tail_hours: float = 0
tail_end_hour: float = 0
continuation_mode: str = "FIXED"
mode: str = Field(
json_schema_extra={
"description": "Terminal value mode the run used: TAIL, AUTO or FIXED.",
"examples": ["TAIL", "AUTO", "FIXED"],
}
)
battery_energy_wh: float = Field(
default=0.0,
json_schema_extra={
"description": "Usable AC energy left in the battery at the end of the horizon [Wh]."
},
)
credited_euro: float = Field(
default=0.0,
json_schema_extra={"description": "Credit applied to the total balance [EUR]."},
)
tail_operating_euro: float = Field(
default=0.0,
json_schema_extra={
"description": (
"Optimal net cash flow within the effective tail for the selected "
"control-end battery state [EUR]."
)
},
)
continuation_value_euro: float = Field(
default=0.0,
json_schema_extra={
"description": (
"Continuation credit remaining at the end of the optimal tail path [EUR]."
)
},
)
curve: Optional[TerminalValueCurve] = Field(
default=None,
json_schema_extra={
"description": (
"Combined tail value curve (tail operation plus continuation) read by fitness; "
"None in FIXED mode."
)
},
)
continuation_curve: Optional[TerminalValueCurve] = Field(
default=None,
json_schema_extra={
"description": "Conservative AUTO proxy constructed at the effective tail end."
},
)
tail_diagnostics: Optional[TailDiagnostics] = None
tail_plan: list[TailPlanSlot] = Field(
default_factory=list,
json_schema_extra={
"description": (
"Diagnostic optimal battery path inside the tail. It explains the "
"lookahead but is never an executable control plan."
)
},
)
reason: str = Field(
default="",
json_schema_extra={
"description": (
"Why this mode applied. Empty in AUTO mode; in FIXED mode it "
"says whether FIXED was configured or whether AUTO fell back "
"because no curve could be derived."
),
"examples": ["", "terminal_value_mode is FIXED"],
},
)
def build_terminal_value_curve(
*,
prices_euro_per_wh: np.ndarray,
load_wh: np.ndarray,
pv_wh: np.ndarray,
feed_in_euro_per_wh: np.ndarray,
max_energy_wh: float,
lcos_euro_per_kwh: float = 0.0,
dc_to_ac_efficiency: float = 1.0,
grid_export_allowed: bool = False,
) -> TerminalValueCurve:
"""Build the terminal value curve from the trailing horizon window.
Every slot of the window contributes its residual load - the part of the
load that PV does not cover - at its import price. Sorting those slots by
price and accumulating them yields the marginal value of the first, second,
... kWh in the battery. Energy beyond the residual load can only be
exported, and only when direct marketing allows it.
Args:
prices_euro_per_wh: Import prices of the window [EUR/Wh].
load_wh: Load per slot of the window [Wh].
pv_wh: PV generation per slot of the window [Wh].
feed_in_euro_per_wh: Feed-in tariff of the window [EUR/Wh].
max_energy_wh: Usable AC energy of a full battery [Wh]; the curve ends here.
lcos_euro_per_kwh: Levelized cost of storage, already charged per
delivered DC energy in the simulation and therefore subtracted here
so stored energy is not credited twice.
dc_to_ac_efficiency: Inverter efficiency, used to convert the LCOS from
delivered DC energy to the AC energy of the curve.
grid_export_allowed: Whether the battery may feed the grid (direct
marketing). Without it, energy beyond the residual load gets no
credit: it can neither be exported nor is its use covered by the
proxy window.
Returns:
The curve; empty when the window carries no usable information.
"""
window = min(len(prices_euro_per_wh), len(load_wh), len(pv_wh))
if window <= 0 or max_energy_wh <= 0.0:
return TerminalValueCurve()
residual = np.maximum(load_wh[:window] - pv_wh[:window], 0.0)
prices = np.asarray(prices_euro_per_wh[:window], dtype=float)
# LCOS is charged on delivered DC energy; the curve is in AC energy.
lcos_per_wh_ac = (lcos_euro_per_kwh / 1000.0) / max(dc_to_ac_efficiency, 1e-9)
order = np.argsort(-prices)
energy_points: list[float] = [0.0]
value_points: list[float] = [0.0]
marginals: list[float] = []
cumulative_energy = 0.0
cumulative_value = 0.0
for index in order:
slot_energy = float(residual[index])
if slot_energy <= 0.0:
continue
# Negative or very cheap hours are not worth storing energy for.
marginal = max(float(prices[index]) - lcos_per_wh_ac, 0.0)
if marginal <= 0.0:
continue
slot_energy = min(slot_energy, max_energy_wh - cumulative_energy)
if slot_energy <= 0.0:
break
cumulative_energy += slot_energy
cumulative_value += slot_energy * marginal
energy_points.append(cumulative_energy)
value_points.append(cumulative_value)
marginals.append(marginal * 1000.0)
# Everything beyond the residual load can only be sold. A median feed-in
# tariff rather than the best one: exporting all of it in the single best
# slot is not something the horizon can promise.
residual_energy_wh = cumulative_energy
if grid_export_allowed and cumulative_energy < max_energy_wh:
positive_feed_in = [
float(value) for value in feed_in_euro_per_wh[:window] if float(value) > 0.0
]
export_marginal = max(
(float(np.median(positive_feed_in)) if positive_feed_in else 0.0) - lcos_per_wh_ac,
0.0,
)
if export_marginal > 0.0:
remaining = max_energy_wh - cumulative_energy
cumulative_energy += remaining
cumulative_value += remaining * export_marginal
energy_points.append(cumulative_energy)
value_points.append(cumulative_value)
marginals.append(export_marginal * 1000.0)
if len(energy_points) <= 1:
logger.debug("Terminal value curve is empty - no priced residual load in the window.")
return TerminalValueCurve(window_slots=window)
# The segment slopes are decreasing by construction (prices were sorted),
# so the curve is concave; the export tail is the flattest segment.
return TerminalValueCurve(
energy_wh=energy_points,
value_euro=value_points,
marginal_euro_per_kwh=marginals,
residual_energy_wh=residual_energy_wh,
window_slots=window,
)
def trailing_window(
values: Optional[np.ndarray],
end_slot: int,
window_slots: int,
) -> np.ndarray:
"""Return the ``window_slots`` values in front of ``end_slot``.
Args:
values: Full slot array, or None.
end_slot: Exclusive end of the window (end of the optimization horizon).
window_slots: Desired window length; a shorter horizon yields less.
Returns:
The window as a float array, empty when no data is available.
"""
if values is None:
return np.zeros(0, dtype=float)
end = min(int(end_slot), len(values))
start = max(end - int(window_slots), 0)
if end <= start:
return np.zeros(0, dtype=float)
return np.asarray(values[start:end], dtype=float)
+12 -3
View File
@@ -1,7 +1,7 @@
from enum import StrEnum
from typing import Optional
from typing import Any, Optional
from pydantic import Field, computed_field
from pydantic import Field, computed_field, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_ems
@@ -10,7 +10,10 @@ from akkudoktoreos.core.pydantic import (
PydanticDateTimeDataFrame,
)
from akkudoktoreos.optimization.genetic0.genetic0settings import Genetic0CommonSettings
from akkudoktoreos.optimization.genetic.geneticsettings import GeneticCommonSettings
from akkudoktoreos.optimization.genetic.geneticsettings import (
GeneticCommonSettings,
normalize_genetic_settings,
)
from akkudoktoreos.utils.datetimeutil import DateTime
@@ -29,6 +32,12 @@ def optimization_default_algorithm() -> OptimizationAlgorithm:
class OptimizationCommonSettings(SettingsBaseModel):
"""General Optimization Configuration."""
@model_validator(mode="before")
@classmethod
def normalize_old_genetic_settings(cls, value: Any) -> Any:
"""Accept the feature branch's flat GENETIC settings without losing values."""
return normalize_genetic_settings(value)
algorithm: OptimizationAlgorithm = Field(
default_factory=optimization_default_algorithm,
json_schema_extra={
@@ -42,6 +42,14 @@ def feedintariff_provider_ids() -> list[str]:
class FeedInTariffCommonSettings(SettingsBaseModel):
"""Feed In Tariff Prediction Configuration."""
direct_marketing_enabled: bool = Field(
default=False,
description=(
"Enable export-aware GENETIC optimization. Sale revenues remain those of the "
"configured feed-in provider or explicit forecast; purchase prices never replace them."
),
)
provider: Optional[str] = Field(
default=None,
json_schema_extra={
+71
View File
@@ -64,7 +64,11 @@ from akkudoktoreos.optimization.genetic0.genetic0solution import (
from akkudoktoreos.optimization.genetic0.genetic0visualize import (
genetic0_prepare_visualize,
)
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.genetic.geneticvisualize import (
genetic_prepare_visualize,
)
from akkudoktoreos.optimization.optimization import (
OptimizationAlgorithm,
OptimizationSolution,
@@ -1867,6 +1871,35 @@ async def fastapi_energy_management_optimization_solution_algorithm_get(
return solution
@app.get(
"/v1/energy-management/optimization/solution/GENETIC/pdf",
tags=["energy-management"],
response_class=Response,
responses={200: {"content": {"application/pdf": {}}}},
)
async def fastapi_energy_management_optimization_solution_genetic_pdf_get() -> Response:
"""Render the retained GENETIC result without rerunning optimization.
Rendering runs outside the event loop. Copy the result before offloading;
its recorded timestamp, interval and inputs own the report's time grid.
The legacy /visualization_results.pdf route continues to serve GENETIC0.
"""
retained = get_ems().genetic_solution()
if retained is None:
raise EOSProblem(
status=404,
title="Optimization solution report retrieval failed",
detail="Can not get the 'GENETIC' optimization solution.",
)
snapshot = retained.model_copy(deep=True)
pdf = await asyncio.to_thread(genetic_prepare_visualize, solution=snapshot)
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": 'inline; filename="optimization-genetic.pdf"'},
)
@app.get("/v1/energy-management/plan", tags=["energy-management"])
def fastapi_energy_management_plan_get() -> EnergyManagementPlan:
"""Get the latest energy management plan."""
@@ -2174,6 +2207,44 @@ async def fastapi_pvforecast() -> ForecastResponse:
return ForecastResponse(temperature=temp_air_list, pvpower=ac_power_list)
@app.post("/v1/optimize", tags=["optimize"])
async def fastapi_optimize_config(
request: Request,
parameters: ConfigOptimizationRequest = Body(default_factory=ConfigOptimizationRequest),
) -> GeneticSolution:
"""Optimize GENETIC using configured devices and optional fresh runtime inputs.
Static settings belong in configuration; query overrides are rejected.
Forecast arrays start at local
midnight and contain Wh per configured GENETIC slot; prices are currency/Wh.
An empty body uses configured providers and fresh measured states of charge.
The deprecated /optimize endpoint continues to run hourly GENETIC0.
"""
if request.query_params:
raise HTTPException(
status_code=422,
detail="Configure optimization settings; query overrides are not supported.",
)
solution = await get_ems().run(
mode=EnergyManagementMode.OPTIMIZATION,
algorithm=OptimizationAlgorithm.GENETIC,
genetic_parameters=parameters,
)
if solution is None:
raise EOSProblem(
status=503,
title="GENETIC optimization failed",
detail="No new solution was produced. Check configured devices, fresh SoC and forecast coverage.",
)
if not isinstance(solution, GeneticSolution):
raise EOSProblem(
status=500,
title="Unexpected optimization algorithm result",
detail="GENETIC did not return its native solution type.",
)
return solution
@app.post("/optimize", tags=["optimize"])
async def fastapi_optimize(
parameters: Genetic0OptimizationParameters,