mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-14 10:01:59 +00:00
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 fromd2e2d58237. 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 commitsf976335,6dc58c3andfaed0fdby 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 fromd2e2d58237. 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 Sourced2e2d58237. 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 commitsf976335,6dc58c3andfaed0fdby 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 fromd2e2d582while 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:
co-authored by
Andreas
Christin
Normann
Bobby Noelte
r0b2g1t
parent
3c862543a1
commit
04f28997ea
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user