2025-10-28 02:50:31 +01:00
|
|
|
|
"""Genetic algorithm."""
|
|
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
import random
|
2024-12-19 14:45:20 +01:00
|
|
|
|
import time
|
2026-07-15 14:19:46 +02:00
|
|
|
|
from collections import OrderedDict, defaultdict
|
|
|
|
|
|
from dataclasses import dataclass, field
|
2024-12-19 14:45:20 +01:00
|
|
|
|
from typing import Any, Optional
|
2024-09-28 22:14:09 +02:00
|
|
|
|
|
2024-10-03 11:05:44 +02:00
|
|
|
|
import numpy as np
|
2024-10-04 03:11:24 +02:00
|
|
|
|
from deap import algorithms, base, creator, tools
|
2025-06-10 22:00:28 +02:00
|
|
|
|
from loguru import logger
|
2025-10-28 02:50:31 +01:00
|
|
|
|
from numpydantic import NDArray, Shape
|
|
|
|
|
|
from pydantic import ConfigDict, Field
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
2026-07-15 14:19:46 +02:00
|
|
|
|
from akkudoktoreos.devices.devicesabc import ConsumerScheduleMode
|
2025-10-28 02:50:31 +01:00
|
|
|
|
from akkudoktoreos.devices.genetic.battery import Battery
|
|
|
|
|
|
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance
|
|
|
|
|
|
from akkudoktoreos.devices.genetic.inverter import Inverter
|
|
|
|
|
|
from akkudoktoreos.optimization.genetic.geneticparams import (
|
|
|
|
|
|
GeneticEnergyManagementParameters,
|
|
|
|
|
|
GeneticOptimizationParameters,
|
2024-12-15 14:40:03 +01:00
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
from akkudoktoreos.optimization.genetic.geneticsolution import (
|
|
|
|
|
|
GeneticSimulationResult,
|
|
|
|
|
|
GeneticSolution,
|
2024-11-26 22:28:05 +01:00
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
from akkudoktoreos.optimization.optimizationabc import OptimizationBase
|
2024-09-30 08:23:38 +02:00
|
|
|
|
|
2024-10-07 19:59:31 +02:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class ApplianceGeneSlot:
|
|
|
|
|
|
"""One appliance start gene in the genome.
|
|
|
|
|
|
|
|
|
|
|
|
The gene value is an **index into ``allowed_start_slots``**, not an absolute
|
|
|
|
|
|
slot. This guarantees every gene value maps to a genuinely valid start and
|
|
|
|
|
|
keeps all allowed starts equally reachable by mutation/crossover.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
gene_index: int
|
|
|
|
|
|
appliance_index: int
|
|
|
|
|
|
device_id: str
|
|
|
|
|
|
run_index: int
|
|
|
|
|
|
# Local calendar date of the run for DAILY appliances; None for ONCE.
|
|
|
|
|
|
run_date: Optional[Any]
|
|
|
|
|
|
allowed_start_slots: list[int]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class ApplianceGeneLayout:
|
|
|
|
|
|
"""Ordered descriptor of the appliance part of the genome.
|
|
|
|
|
|
|
|
|
|
|
|
Every genome-building step (create/split/merge/mutate/decode) consumes only
|
|
|
|
|
|
this descriptor, so the appliance gene block can vary in length with the
|
|
|
|
|
|
number of devices and DAILY run days without any hard-coded gene positions.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
genes: list[ApplianceGeneSlot] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def n_genes(self) -> int:
|
|
|
|
|
|
"""Number of appliance start genes."""
|
|
|
|
|
|
return len(self.genes)
|
|
|
|
|
|
|
|
|
|
|
|
def signature(self) -> tuple:
|
|
|
|
|
|
"""Stable identity of the layout for start-solution compatibility.
|
|
|
|
|
|
|
|
|
|
|
|
Two layouts with the same length can still describe different schedules;
|
|
|
|
|
|
the signature captures device, run date and the allowed-start list so a
|
|
|
|
|
|
cached start solution built for a different layout is not silently
|
|
|
|
|
|
reused.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return tuple(
|
|
|
|
|
|
(gene.device_id, str(gene.run_date), tuple(gene.allowed_start_slots))
|
|
|
|
|
|
for gene in self.genes
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
class GeneticSimulation(PydanticBaseModel):
|
|
|
|
|
|
"""Device simulation for GENETIC optimization algorithm."""
|
|
|
|
|
|
|
|
|
|
|
|
# Disable validation on assignment to speed up simulation runs.
|
|
|
|
|
|
model_config = ConfigDict(
|
|
|
|
|
|
validate_assignment=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
start_hour: int = Field(
|
2025-11-10 16:57:44 +01:00
|
|
|
|
default=0,
|
|
|
|
|
|
ge=0,
|
|
|
|
|
|
le=23,
|
|
|
|
|
|
json_schema_extra={"description": "Starting hour on day for optimizations."},
|
2025-10-28 02:50:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
optimization_hours: Optional[int] = Field(
|
2025-11-10 16:57:44 +01:00
|
|
|
|
default=24,
|
|
|
|
|
|
ge=0,
|
|
|
|
|
|
json_schema_extra={"description": "Number of hours into the future for optimizations."},
|
2025-10-28 02:50:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
prediction_hours: Optional[int] = Field(
|
2025-11-10 16:57:44 +01:00
|
|
|
|
default=48,
|
|
|
|
|
|
ge=0,
|
|
|
|
|
|
json_schema_extra={"description": "Number of hours into the future for predictions"},
|
2025-10-28 02:50:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
load_energy_array: Optional[NDArray[Shape["*"], float]] = Field(
|
2024-11-26 22:28:05 +01:00
|
|
|
|
default=None,
|
2025-11-10 16:57:44 +01:00
|
|
|
|
json_schema_extra={
|
|
|
|
|
|
"description": "An array of floats representing the total load (consumption) in watts for different time intervals."
|
|
|
|
|
|
},
|
2024-11-15 22:27:25 +01:00
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
pv_prediction_wh: Optional[NDArray[Shape["*"], float]] = Field(
|
2024-11-26 22:28:05 +01:00
|
|
|
|
default=None,
|
2025-11-10 16:57:44 +01:00
|
|
|
|
json_schema_extra={
|
|
|
|
|
|
"description": "An array of floats representing the forecasted photovoltaic output in watts for different time intervals."
|
|
|
|
|
|
},
|
2024-11-15 22:27:25 +01:00
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
elect_price_hourly: Optional[NDArray[Shape["*"], float]] = Field(
|
2024-11-26 22:28:05 +01:00
|
|
|
|
default=None,
|
2025-11-10 16:57:44 +01:00
|
|
|
|
json_schema_extra={
|
|
|
|
|
|
"description": "An array of floats representing the electricity price in euros per watt-hour for different time intervals."
|
|
|
|
|
|
},
|
2025-10-28 02:50:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
elect_revenue_per_hour_arr: Optional[NDArray[Shape["*"], float]] = Field(
|
|
|
|
|
|
default=None,
|
2025-11-10 16:57:44 +01:00
|
|
|
|
json_schema_extra={
|
|
|
|
|
|
"description": "An array of floats representing the feed-in compensation in euros per watt-hour."
|
|
|
|
|
|
},
|
2024-11-15 22:27:25 +01:00
|
|
|
|
)
|
2026-07-12 09:01:11 +02:00
|
|
|
|
direct_marketing_enabled: bool = Field(
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
json_schema_extra={
|
|
|
|
|
|
"description": "Use direct marketing behavior for feed-in/export decisions."
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2025-11-10 16:57:44 +01:00
|
|
|
|
battery: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
|
|
|
|
|
|
ev: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
|
2026-07-15 14:19:46 +02:00
|
|
|
|
home_appliances: list[HomeAppliance] = Field(
|
|
|
|
|
|
default_factory=list,
|
|
|
|
|
|
json_schema_extra={"description": "Flexible consumers scheduled by the optimizer."},
|
2025-11-10 16:57:44 +01:00
|
|
|
|
)
|
|
|
|
|
|
inverter: Optional[Inverter] = Field(default=None, json_schema_extra={"description": "TBD."})
|
2024-11-26 22:28:05 +01:00
|
|
|
|
|
2025-11-10 16:57:44 +01:00
|
|
|
|
ac_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(
|
|
|
|
|
|
default=None, json_schema_extra={"description": "TBD"}
|
|
|
|
|
|
)
|
|
|
|
|
|
dc_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(
|
|
|
|
|
|
default=None, json_schema_extra={"description": "TBD"}
|
|
|
|
|
|
)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
bat_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field(
|
2025-11-10 16:57:44 +01:00
|
|
|
|
default=None, json_schema_extra={"description": "TBD"}
|
|
|
|
|
|
)
|
2026-07-12 09:01:11 +02:00
|
|
|
|
bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field(
|
|
|
|
|
|
default=None,
|
2026-07-14 17:00:07 +02:00
|
|
|
|
json_schema_extra={"description": "Hourly permission for battery discharge into the grid."},
|
2026-07-12 09:01:11 +02:00
|
|
|
|
)
|
2025-11-10 16:57:44 +01:00
|
|
|
|
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(
|
|
|
|
|
|
default=None, json_schema_extra={"description": "TBD"}
|
2025-11-08 15:42:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
ev_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field(
|
2025-11-10 16:57:44 +01:00
|
|
|
|
default=None, json_schema_extra={"description": "TBD"}
|
2025-11-08 15:42:18 +01:00
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
def prepare(
|
|
|
|
|
|
self,
|
|
|
|
|
|
parameters: GeneticEnergyManagementParameters,
|
|
|
|
|
|
optimization_hours: int,
|
|
|
|
|
|
prediction_hours: int,
|
|
|
|
|
|
ev: Optional[Battery] = None,
|
2026-07-15 14:19:46 +02:00
|
|
|
|
home_appliances: Optional[list[HomeAppliance]] = None,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
inverter: Optional[Inverter] = None,
|
2026-07-12 09:01:11 +02:00
|
|
|
|
direct_marketing_enabled: bool = False,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
) -> None:
|
2026-03-17 12:41:15 +01:00
|
|
|
|
"""Prepare simulation runs.
|
|
|
|
|
|
|
|
|
|
|
|
Populate internal arrays and device references used during simulation.
|
|
|
|
|
|
"""
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.optimization_hours = optimization_hours
|
|
|
|
|
|
self.prediction_hours = prediction_hours
|
2026-07-12 09:01:11 +02:00
|
|
|
|
self.direct_marketing_enabled = direct_marketing_enabled
|
2026-03-17 12:41:15 +01:00
|
|
|
|
|
|
|
|
|
|
# Load arrays from provided EMS parameters
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.load_energy_array = np.array(parameters.gesamtlast, float)
|
|
|
|
|
|
self.pv_prediction_wh = np.array(parameters.pv_prognose_wh, float)
|
|
|
|
|
|
self.elect_price_hourly = np.array(parameters.strompreis_euro_pro_wh, float)
|
|
|
|
|
|
self.elect_revenue_per_hour_arr = (
|
2026-07-12 09:01:11 +02:00
|
|
|
|
np.array(parameters.einspeiseverguetung_euro_pro_wh, float)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
if isinstance(parameters.einspeiseverguetung_euro_pro_wh, list)
|
|
|
|
|
|
else np.full(
|
|
|
|
|
|
len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-03-17 12:41:15 +01:00
|
|
|
|
|
|
|
|
|
|
# Associate devices
|
2025-10-28 02:50:31 +01:00
|
|
|
|
if inverter:
|
|
|
|
|
|
self.battery = inverter.battery
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.battery = None
|
|
|
|
|
|
self.ev = ev
|
2026-07-15 14:19:46 +02:00
|
|
|
|
self.home_appliances = home_appliances or []
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.inverter = inverter
|
2026-03-17 12:41:15 +01:00
|
|
|
|
|
|
|
|
|
|
# Initialize per-hour action arrays for the prediction horizon
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.ac_charge_hours = np.full(self.prediction_hours, 0.0)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
self.dc_charge_hours = np.full(self.prediction_hours, 0.0)
|
|
|
|
|
|
self.bat_discharge_hours = np.full(self.prediction_hours, 0.0)
|
2026-07-12 09:01:11 +02:00
|
|
|
|
self.bat_grid_export_hours = np.full(self.prediction_hours, 0.0)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.ev_charge_hours = np.full(self.prediction_hours, 0.0)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
self.ev_discharge_hours = np.full(self.prediction_hours, 0.0)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
def reset(self) -> None:
|
|
|
|
|
|
if self.ev:
|
|
|
|
|
|
self.ev.reset()
|
|
|
|
|
|
if self.battery:
|
|
|
|
|
|
self.battery.reset()
|
|
|
|
|
|
|
|
|
|
|
|
def simulate(self, start_hour: int) -> dict[str, Any]:
|
|
|
|
|
|
"""Simulate energy usage and costs for the given start hour.
|
|
|
|
|
|
|
|
|
|
|
|
akku_soc_pro_stunde begin of the hour, initial hour state!
|
|
|
|
|
|
last_wh_pro_stunde integral of last hour (end state)
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Remember start hour
|
|
|
|
|
|
self.start_hour = start_hour
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# Provide fast (3x..5x) local read access (vs. self.xxx) for repetitive read access
|
|
|
|
|
|
load_energy_array_fast = self.load_energy_array
|
|
|
|
|
|
ev_charge_hours_fast = self.ev_charge_hours
|
|
|
|
|
|
ev_discharge_hours_fast = self.ev_discharge_hours
|
|
|
|
|
|
ac_charge_hours_fast = self.ac_charge_hours
|
|
|
|
|
|
dc_charge_hours_fast = self.dc_charge_hours
|
|
|
|
|
|
bat_discharge_hours_fast = self.bat_discharge_hours
|
2026-07-12 09:01:11 +02:00
|
|
|
|
bat_grid_export_hours_fast = self.bat_grid_export_hours
|
2025-11-08 15:42:18 +01:00
|
|
|
|
elect_price_hourly_fast = self.elect_price_hourly
|
|
|
|
|
|
elect_revenue_per_hour_arr_fast = self.elect_revenue_per_hour_arr
|
|
|
|
|
|
pv_prediction_wh_fast = self.pv_prediction_wh
|
|
|
|
|
|
battery_fast = self.battery
|
|
|
|
|
|
ev_fast = self.ev
|
2026-07-15 14:19:46 +02:00
|
|
|
|
home_appliances_fast = self.home_appliances
|
2025-11-08 15:42:18 +01:00
|
|
|
|
inverter_fast = self.inverter
|
2026-07-12 09:01:11 +02:00
|
|
|
|
direct_marketing_enabled_fast = self.direct_marketing_enabled
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# Check for simulation integrity (in a way that mypy understands)
|
|
|
|
|
|
if (
|
|
|
|
|
|
load_energy_array_fast is None
|
|
|
|
|
|
or pv_prediction_wh_fast is None
|
|
|
|
|
|
or elect_price_hourly_fast is None
|
|
|
|
|
|
or ev_charge_hours_fast is None
|
|
|
|
|
|
or ac_charge_hours_fast is None
|
|
|
|
|
|
or dc_charge_hours_fast is None
|
|
|
|
|
|
or elect_revenue_per_hour_arr_fast is None
|
|
|
|
|
|
or bat_discharge_hours_fast is None
|
2026-07-12 09:01:11 +02:00
|
|
|
|
or bat_grid_export_hours_fast is None
|
2025-11-08 15:42:18 +01:00
|
|
|
|
or ev_discharge_hours_fast is None
|
|
|
|
|
|
):
|
|
|
|
|
|
missing = []
|
|
|
|
|
|
if load_energy_array_fast is None:
|
|
|
|
|
|
missing.append("Load Energy Array")
|
|
|
|
|
|
if pv_prediction_wh_fast is None:
|
|
|
|
|
|
missing.append("PV Prediction Wh")
|
|
|
|
|
|
if elect_price_hourly_fast is None:
|
|
|
|
|
|
missing.append("Electricity Price Hourly")
|
|
|
|
|
|
if ev_charge_hours_fast is None:
|
|
|
|
|
|
missing.append("EV Charge Hours")
|
|
|
|
|
|
if ac_charge_hours_fast is None:
|
|
|
|
|
|
missing.append("AC Charge Hours")
|
|
|
|
|
|
if dc_charge_hours_fast is None:
|
|
|
|
|
|
missing.append("DC Charge Hours")
|
|
|
|
|
|
if elect_revenue_per_hour_arr_fast is None:
|
|
|
|
|
|
missing.append("Electricity Revenue Per Hour")
|
|
|
|
|
|
if bat_discharge_hours_fast is None:
|
|
|
|
|
|
missing.append("Battery Discharge Hours")
|
2026-07-12 09:01:11 +02:00
|
|
|
|
if bat_grid_export_hours_fast is None:
|
|
|
|
|
|
missing.append("Battery Grid Export Hours")
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if ev_discharge_hours_fast is None:
|
|
|
|
|
|
missing.append("EV Discharge Hours")
|
|
|
|
|
|
msg = ", ".join(missing)
|
|
|
|
|
|
logger.error("Mandatory data missing - %s", msg)
|
|
|
|
|
|
raise ValueError(f"Mandatory data missing: {msg}")
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if not (
|
|
|
|
|
|
len(load_energy_array_fast)
|
|
|
|
|
|
== len(pv_prediction_wh_fast)
|
|
|
|
|
|
== len(elect_price_hourly_fast)
|
|
|
|
|
|
):
|
|
|
|
|
|
error_msg = f"Array sizes do not match: Load Curve = {len(load_energy_array_fast)}, PV Forecast = {len(pv_prediction_wh_fast)}, Electricity Price = {len(elect_price_hourly_fast)}"
|
2025-10-28 02:50:31 +01:00
|
|
|
|
logger.error(error_msg)
|
|
|
|
|
|
raise ValueError(error_msg)
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
end_hour = len(load_energy_array_fast)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
total_hours = end_hour - start_hour
|
|
|
|
|
|
|
|
|
|
|
|
# Pre-allocate arrays for the results, optimized for speed
|
|
|
|
|
|
loads_energy_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
feedin_energy_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
consumption_energy_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
costs_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
revenue_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
losses_wh_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
electricity_price_per_hour = np.full((total_hours), np.nan)
|
2026-07-12 09:01:11 +02:00
|
|
|
|
feed_in_tariff_per_hour = np.full((total_hours), np.nan)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
# Set initial state
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if battery_fast:
|
2025-12-30 22:08:21 +01:00
|
|
|
|
# Pre-allocate arrays for the results, optimized for speed
|
|
|
|
|
|
soc_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
soc_per_hour[0] = battery_fast.current_soc_percentage()
|
2026-02-27 23:12:08 +01:00
|
|
|
|
|
|
|
|
|
|
# Determine AC charging availability from inverter parameters
|
|
|
|
|
|
if inverter_fast:
|
|
|
|
|
|
ac_to_dc_eff_fast = inverter_fast.ac_to_dc_efficiency
|
|
|
|
|
|
dc_to_ac_eff_fast = inverter_fast.dc_to_ac_efficiency
|
|
|
|
|
|
max_ac_charge_w_fast = inverter_fast.max_ac_charge_power_w
|
|
|
|
|
|
else:
|
|
|
|
|
|
ac_to_dc_eff_fast = 1.0
|
|
|
|
|
|
dc_to_ac_eff_fast = 1.0
|
|
|
|
|
|
max_ac_charge_w_fast = None
|
|
|
|
|
|
|
|
|
|
|
|
ac_charging_possible = ac_to_dc_eff_fast > 0 and (
|
|
|
|
|
|
max_ac_charge_w_fast is None or max_ac_charge_w_fast > 0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# If AC charging is disabled via inverter, zero out AC charge hours
|
|
|
|
|
|
if not ac_charging_possible:
|
|
|
|
|
|
ac_charge_hours_fast = np.zeros_like(ac_charge_hours_fast)
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# Fill the charge array of the battery
|
|
|
|
|
|
dc_charge_hours_fast[0:start_hour] = 0
|
|
|
|
|
|
dc_charge_hours_fast[end_hour:] = 0
|
|
|
|
|
|
ac_charge_hours_fast[0:start_hour] = 0
|
2026-02-27 23:12:08 +01:00
|
|
|
|
ac_charge_hours_fast[end_hour:] = 0
|
2025-11-08 15:42:18 +01:00
|
|
|
|
battery_fast.charge_array = np.where(
|
|
|
|
|
|
ac_charge_hours_fast != 0, ac_charge_hours_fast, dc_charge_hours_fast
|
|
|
|
|
|
)
|
|
|
|
|
|
# Fill the discharge array of the battery
|
|
|
|
|
|
bat_discharge_hours_fast[0:start_hour] = 0
|
|
|
|
|
|
bat_discharge_hours_fast[end_hour:] = 0
|
2026-07-12 09:01:11 +02:00
|
|
|
|
bat_grid_export_hours_fast[0:start_hour] = 0
|
|
|
|
|
|
bat_grid_export_hours_fast[end_hour:] = 0
|
|
|
|
|
|
battery_fast.discharge_array = np.where(
|
|
|
|
|
|
(bat_discharge_hours_fast > 0)
|
|
|
|
|
|
| (
|
|
|
|
|
|
direct_marketing_enabled_fast
|
|
|
|
|
|
& (bat_grid_export_hours_fast > 0)
|
|
|
|
|
|
& (elect_revenue_per_hour_arr_fast > 0.0)
|
|
|
|
|
|
),
|
|
|
|
|
|
1,
|
|
|
|
|
|
0,
|
|
|
|
|
|
)
|
2025-12-30 22:08:21 +01:00
|
|
|
|
else:
|
|
|
|
|
|
# Default return if no battery is available
|
|
|
|
|
|
soc_per_hour = np.full((total_hours), 0)
|
2026-02-27 23:12:08 +01:00
|
|
|
|
ac_to_dc_eff_fast = 1.0
|
|
|
|
|
|
dc_to_ac_eff_fast = 1.0
|
|
|
|
|
|
max_ac_charge_w_fast = None
|
|
|
|
|
|
ac_charging_possible = False
|
2025-11-08 15:42:18 +01:00
|
|
|
|
|
|
|
|
|
|
if ev_fast:
|
2025-12-30 22:08:21 +01:00
|
|
|
|
# Pre-allocate arrays for the results, optimized for speed
|
|
|
|
|
|
soc_ev_per_hour = np.full((total_hours), np.nan)
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
soc_ev_per_hour[0] = ev_fast.current_soc_percentage()
|
|
|
|
|
|
# Fill the charge array of the ev
|
|
|
|
|
|
ev_charge_hours_fast[0:start_hour] = 0
|
|
|
|
|
|
ev_charge_hours_fast[end_hour:] = 0
|
|
|
|
|
|
ev_fast.charge_array = ev_charge_hours_fast
|
|
|
|
|
|
# Fill the discharge array of the ev
|
|
|
|
|
|
ev_discharge_hours_fast[0:start_hour] = 0
|
|
|
|
|
|
ev_discharge_hours_fast[end_hour:] = 0
|
|
|
|
|
|
ev_fast.discharge_array = ev_discharge_hours_fast
|
2025-12-30 22:08:21 +01:00
|
|
|
|
else:
|
|
|
|
|
|
# Default return if no electric vehicle is available
|
|
|
|
|
|
soc_ev_per_hour = np.full((total_hours), 0)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
if home_appliances_fast:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
home_appliance_enabled = True
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Pre-allocate the aggregate appliance load array (sum over all
|
|
|
|
|
|
# devices). Each appliance already carries its own resampled load
|
|
|
|
|
|
# curve, built from the decoded start(s) before this call.
|
2025-12-30 22:08:21 +01:00
|
|
|
|
home_appliance_wh_per_hour = np.full((total_hours), np.nan)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
else:
|
|
|
|
|
|
home_appliance_enabled = False
|
2025-12-30 22:08:21 +01:00
|
|
|
|
# Default return if no home appliance is available
|
|
|
|
|
|
home_appliance_wh_per_hour = np.full((total_hours), 0)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
for hour in range(start_hour, end_hour):
|
|
|
|
|
|
hour_idx = hour - start_hour
|
|
|
|
|
|
|
|
|
|
|
|
# Accumulate loads and PV generation
|
2025-11-08 15:42:18 +01:00
|
|
|
|
consumption = load_energy_array_fast[hour]
|
2025-10-28 02:50:31 +01:00
|
|
|
|
losses_wh_per_hour[hour_idx] = 0.0
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Home appliances (sum the per-slot load of all flexible consumers)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if home_appliance_enabled:
|
2026-07-15 14:19:46 +02:00
|
|
|
|
ha_load = 0.0
|
|
|
|
|
|
for appliance in home_appliances_fast:
|
|
|
|
|
|
ha_load += appliance.get_load_for_hour(hour)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
consumption += ha_load
|
|
|
|
|
|
home_appliance_wh_per_hour[hour_idx] = ha_load
|
|
|
|
|
|
|
|
|
|
|
|
# E-Auto handling
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if ev_fast:
|
|
|
|
|
|
soc_ev_per_hour[hour_idx] = ev_fast.current_soc_percentage() # save begin state
|
|
|
|
|
|
if ev_charge_hours_fast[hour] > 0:
|
|
|
|
|
|
loaded_energy_ev, verluste_eauto = ev_fast.charge_energy(
|
|
|
|
|
|
wh=None, hour=hour, charge_factor=ev_charge_hours_fast[hour]
|
|
|
|
|
|
)
|
|
|
|
|
|
consumption += loaded_energy_ev
|
|
|
|
|
|
losses_wh_per_hour[hour_idx] += verluste_eauto
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Save battery SOC before inverter processing = true begin-of-interval state.
|
|
|
|
|
|
# Must be recorded here (before DC charge/discharge) so the displayed SOC at
|
|
|
|
|
|
# timestamp T reflects what the battery actually had at the START of interval T,
|
|
|
|
|
|
# not the post-DC result. Consistent with the EV SOC convention above.
|
|
|
|
|
|
if battery_fast:
|
|
|
|
|
|
soc_per_hour[hour_idx] = battery_fast.current_soc_percentage()
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
# Process inverter logic
|
|
|
|
|
|
energy_feedin_grid_actual = energy_consumption_grid_actual = losses = eigenverbrauch = (
|
|
|
|
|
|
0.0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if inverter_fast:
|
|
|
|
|
|
energy_produced = pv_prediction_wh_fast[hour]
|
2026-07-12 09:01:11 +02:00
|
|
|
|
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
|
|
|
|
|
battery_grid_export_allowed = (
|
|
|
|
|
|
direct_marketing_enabled_fast
|
|
|
|
|
|
and hourly_feed_in_tariff > 0.0
|
|
|
|
|
|
and bat_grid_export_hours_fast[hour] > 0
|
|
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
(
|
|
|
|
|
|
energy_feedin_grid_actual,
|
|
|
|
|
|
energy_consumption_grid_actual,
|
|
|
|
|
|
losses,
|
|
|
|
|
|
eigenverbrauch,
|
2026-07-12 09:01:11 +02:00
|
|
|
|
) = inverter_fast.process_energy(
|
|
|
|
|
|
energy_produced,
|
|
|
|
|
|
consumption,
|
|
|
|
|
|
hour,
|
|
|
|
|
|
allow_battery_grid_export=battery_grid_export_allowed,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
# AC PV Battery Charge
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if battery_fast:
|
|
|
|
|
|
hour_ac_charge = ac_charge_hours_fast[hour]
|
2026-02-27 23:12:08 +01:00
|
|
|
|
if hour_ac_charge > 0.0 and ac_charging_possible:
|
|
|
|
|
|
# Cap charge factor by max_ac_charge_power_w if set
|
|
|
|
|
|
effective_charge_factor = hour_ac_charge
|
|
|
|
|
|
if max_ac_charge_w_fast is not None and battery_fast.max_charge_power_w > 0:
|
|
|
|
|
|
# DC power = max_charge_power_w * factor
|
|
|
|
|
|
# AC power = DC power / ac_to_dc_eff
|
|
|
|
|
|
# AC power must be <= max_ac_charge_power_w
|
|
|
|
|
|
max_dc_factor = (
|
|
|
|
|
|
max_ac_charge_w_fast * ac_to_dc_eff_fast
|
|
|
|
|
|
) / battery_fast.max_charge_power_w
|
|
|
|
|
|
effective_charge_factor = min(effective_charge_factor, max_dc_factor)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
2026-02-27 23:12:08 +01:00
|
|
|
|
if effective_charge_factor > 0:
|
|
|
|
|
|
battery_charged_energy_actual, battery_losses_actual = (
|
|
|
|
|
|
battery_fast.charge_energy(
|
|
|
|
|
|
None, hour, charge_factor=effective_charge_factor
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# DC energy entering the battery (before battery internal efficiency)
|
|
|
|
|
|
dc_energy = battery_charged_energy_actual + battery_losses_actual
|
|
|
|
|
|
# AC energy consumed from grid (accounts for AC→DC conversion loss)
|
|
|
|
|
|
ac_energy = dc_energy / ac_to_dc_eff_fast
|
|
|
|
|
|
# Inverter AC→DC conversion losses
|
|
|
|
|
|
inverter_charge_losses = ac_energy - dc_energy
|
|
|
|
|
|
|
|
|
|
|
|
consumption += ac_energy
|
|
|
|
|
|
energy_consumption_grid_actual += ac_energy
|
|
|
|
|
|
losses_wh_per_hour[hour_idx] += (
|
|
|
|
|
|
battery_losses_actual + inverter_charge_losses
|
|
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
# Update hourly arrays
|
2026-07-12 09:01:11 +02:00
|
|
|
|
if (
|
|
|
|
|
|
direct_marketing_enabled_fast
|
|
|
|
|
|
and hourly_feed_in_tariff < 0.0
|
|
|
|
|
|
and energy_feedin_grid_actual > 0.0
|
|
|
|
|
|
):
|
|
|
|
|
|
losses_wh_per_hour[hour_idx] += energy_feedin_grid_actual
|
|
|
|
|
|
energy_feedin_grid_actual = 0.0
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
feedin_energy_per_hour[hour_idx] = energy_feedin_grid_actual
|
|
|
|
|
|
consumption_energy_per_hour[hour_idx] = energy_consumption_grid_actual
|
|
|
|
|
|
losses_wh_per_hour[hour_idx] += losses
|
|
|
|
|
|
loads_energy_per_hour[hour_idx] = consumption
|
2025-11-08 15:42:18 +01:00
|
|
|
|
hourly_electricity_price = elect_price_hourly_fast[hour]
|
2025-10-28 02:50:31 +01:00
|
|
|
|
electricity_price_per_hour[hour_idx] = hourly_electricity_price
|
2026-07-12 09:01:11 +02:00
|
|
|
|
feed_in_tariff_per_hour[hour_idx] = hourly_feed_in_tariff
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
# Financial calculations
|
2026-07-15 08:52:16 +02:00
|
|
|
|
grid_cost = energy_consumption_grid_actual * hourly_electricity_price
|
|
|
|
|
|
# LCOS is charged exactly once on battery-delivered DC energy. It is
|
|
|
|
|
|
# not charged on input energy, internal discharge losses, or the
|
|
|
|
|
|
# downstream DC-to-AC inverter loss.
|
|
|
|
|
|
battery_lcos_cost = 0.0
|
|
|
|
|
|
if battery_fast:
|
|
|
|
|
|
battery_lcos_cost = (
|
|
|
|
|
|
battery_fast.discharged_energy_wh(hour)
|
|
|
|
|
|
* battery_fast.levelized_cost_of_storage_kwh
|
|
|
|
|
|
/ 1000.0
|
|
|
|
|
|
)
|
|
|
|
|
|
costs_per_hour[hour_idx] = grid_cost + battery_lcos_cost
|
2026-07-14 17:00:07 +02:00
|
|
|
|
revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_feed_in_tariff
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
total_cost = np.nansum(costs_per_hour)
|
|
|
|
|
|
total_losses = np.nansum(losses_wh_per_hour)
|
|
|
|
|
|
total_revenue = np.nansum(revenue_per_hour)
|
|
|
|
|
|
|
|
|
|
|
|
# Prepare output dictionary
|
|
|
|
|
|
return {
|
|
|
|
|
|
"Last_Wh_pro_Stunde": loads_energy_per_hour,
|
|
|
|
|
|
"Netzeinspeisung_Wh_pro_Stunde": feedin_energy_per_hour,
|
|
|
|
|
|
"Netzbezug_Wh_pro_Stunde": consumption_energy_per_hour,
|
|
|
|
|
|
"Kosten_Euro_pro_Stunde": costs_per_hour,
|
|
|
|
|
|
"akku_soc_pro_stunde": soc_per_hour,
|
|
|
|
|
|
"Einnahmen_Euro_pro_Stunde": revenue_per_hour,
|
2025-11-08 15:42:18 +01:00
|
|
|
|
"Gesamtbilanz_Euro": total_cost - total_revenue, # Fitness score ("FitnessMin")
|
2025-10-28 02:50:31 +01:00
|
|
|
|
"EAuto_SoC_pro_Stunde": soc_ev_per_hour,
|
|
|
|
|
|
"Gesamteinnahmen_Euro": total_revenue,
|
|
|
|
|
|
"Gesamtkosten_Euro": total_cost,
|
|
|
|
|
|
"Verluste_Pro_Stunde": losses_wh_per_hour,
|
|
|
|
|
|
"Gesamt_Verluste": total_losses,
|
|
|
|
|
|
"Home_appliance_wh_per_hour": home_appliance_wh_per_hour,
|
|
|
|
|
|
"Electricity_price": electricity_price_per_hour,
|
2026-07-12 09:01:11 +02:00
|
|
|
|
"Feed_in_tariff": feed_in_tariff_per_hour,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
}
|
2024-11-15 22:27:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
class GeneticOptimization(OptimizationBase):
|
|
|
|
|
|
"""GENETIC algorithm to solve energy optimization."""
|
|
|
|
|
|
|
2026-07-16 09:56:50 +02:00
|
|
|
|
WARM_START_COPIES = 10
|
|
|
|
|
|
WARM_START_MUTATIONS = 20
|
|
|
|
|
|
EDUCATED_GUESS_EXPORT_QUANTILES = (0.60, 0.75, 0.90)
|
|
|
|
|
|
|
2026-06-28 15:45:26 +00:00
|
|
|
|
# Slot-math helpers — single source of truth for the optimization grid.
|
|
|
|
|
|
# At the default optimization interval of 3600 s, slot_duration_h is 1.0 and
|
|
|
|
|
|
# total_slots equals prediction.hours, so the established hourly behaviour is
|
|
|
|
|
|
# preserved. At 900 s (15 min) slot_duration_h is 0.25 and there are 4x as
|
|
|
|
|
|
# many slots.
|
|
|
|
|
|
@property
|
|
|
|
|
|
def slot_duration_h(self) -> float:
|
|
|
|
|
|
"""Length of one optimization slot in hours (1.0 hourly, 0.25 at 15 min)."""
|
|
|
|
|
|
interval = self.config.optimization.interval or 3600
|
|
|
|
|
|
return interval / 3600
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def slots_per_hour(self) -> int:
|
|
|
|
|
|
"""Number of optimization slots per hour (1 hourly, 4 at 15 min)."""
|
|
|
|
|
|
interval = self.config.optimization.interval or 3600
|
|
|
|
|
|
return 3600 // interval
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def total_slots(self) -> int:
|
|
|
|
|
|
"""Total number of optimization slots = prediction.hours * slots_per_hour."""
|
|
|
|
|
|
# Read prediction.hours directly to avoid recursing through total_slots.
|
|
|
|
|
|
return int(self.config.prediction.hours * self.slots_per_hour)
|
|
|
|
|
|
|
|
|
|
|
|
def _start_day_slot(self) -> int:
|
|
|
|
|
|
"""Slot index of ems.start_datetime counted from the start day's midnight.
|
|
|
|
|
|
|
|
|
|
|
|
simulate()/evaluate() use the simulation start position as a slot index
|
|
|
|
|
|
into the prediction/charge arrays. Those arrays begin at the midnight of
|
|
|
|
|
|
``ems.start_datetime`` (geneticparams sets ``start_datetime.set(hour=0)``),
|
|
|
|
|
|
so the index is computed from the same datetime — no timezone conversion —
|
|
|
|
|
|
keeping it consistent with how the arrays are built. At interval=3600 s
|
|
|
|
|
|
slots_per_hour == 1 and minute // 60 == 0, so this reduces to
|
|
|
|
|
|
``start_datetime.hour`` (the previous hourly behaviour).
|
|
|
|
|
|
"""
|
|
|
|
|
|
sd = self.ems.start_datetime
|
|
|
|
|
|
sph = self.slots_per_hour
|
|
|
|
|
|
slot_minutes = max(1, 60 // sph)
|
|
|
|
|
|
return sd.hour * sph + sd.minute // slot_minutes
|
|
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
verbose: bool = False,
|
|
|
|
|
|
fixed_seed: Optional[int] = None,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Initialize the optimization problem with the required parameters."""
|
2026-07-14 17:00:07 +02:00
|
|
|
|
if self.config.optimization.interval not in (900, 3600):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Genetic optimization interval {} seconds is unsupported; using 3600 seconds.",
|
|
|
|
|
|
self.config.optimization.interval,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.config.optimization.interval = 3600
|
2024-11-26 22:28:05 +01:00
|
|
|
|
self.opti_param: dict[str, Any] = {}
|
2026-06-28 15:45:26 +00:00
|
|
|
|
# Number of slots at the tail of the optimization window where EV
|
|
|
|
|
|
# charging is fixed to 0. Slot-counted so 15-min runs reserve the right
|
|
|
|
|
|
# tail length (at interval=3600 s this equals prediction.hours - horizon).
|
2026-07-14 17:00:07 +02:00
|
|
|
|
self.fixed_eauto_hours = max(
|
|
|
|
|
|
self.total_slots
|
|
|
|
|
|
- (
|
|
|
|
|
|
self._start_day_slot()
|
|
|
|
|
|
+ self.config.optimization.horizon_hours * self.slots_per_hour
|
|
|
|
|
|
),
|
|
|
|
|
|
0,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
self.ev_possible_charge_values: list[float] = [1.0]
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Separate charge-level list for battery AC charging (independent of EV rates).
|
|
|
|
|
|
# Populated from parameters.pv_akku.charge_rates in optimierung_ems.
|
|
|
|
|
|
self.bat_possible_charge_values: list[float] = [1.0]
|
2024-10-04 03:11:24 +02:00
|
|
|
|
self.verbose = verbose
|
|
|
|
|
|
self.fix_seed = fixed_seed
|
2024-10-14 10:10:12 +02:00
|
|
|
|
self.optimize_ev = True
|
2024-10-20 18:18:06 +02:00
|
|
|
|
self.optimize_dc_charge = False
|
2026-07-12 09:01:11 +02:00
|
|
|
|
self.optimize_battery_grid_export = False
|
2025-01-12 14:33:02 +01:00
|
|
|
|
self.fitness_history: dict[str, Any] = {}
|
2024-09-30 08:23:38 +02:00
|
|
|
|
|
2025-01-12 14:33:02 +01:00
|
|
|
|
# Set a fixed seed for random operations if provided or in debug mode
|
|
|
|
|
|
if self.fix_seed is not None:
|
|
|
|
|
|
random.seed(self.fix_seed)
|
2025-01-27 21:18:15 +01:00
|
|
|
|
elif logger.level == "DEBUG":
|
2025-06-03 08:30:37 +02:00
|
|
|
|
self.fix_seed = random.randint(1, 100000000000) # noqa: S311
|
2025-01-12 14:33:02 +01:00
|
|
|
|
random.seed(self.fix_seed)
|
2024-10-04 03:11:24 +02:00
|
|
|
|
|
2026-07-14 17:54:15 +02:00
|
|
|
|
# Per-run cache for the AC-charge break-even penalty (see evaluate()).
|
|
|
|
|
|
self._ac_break_even_best_prices: Optional[list[float]] = None
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Appliance genome layout, built once per optimization run in
|
|
|
|
|
|
# optimierung_ems(). Empty by default so setup_deap_environment() can be
|
|
|
|
|
|
# exercised standalone (e.g. in tests) without appliances.
|
|
|
|
|
|
self.appliance_layout: ApplianceGeneLayout = ApplianceGeneLayout([])
|
|
|
|
|
|
# Local datetime of slot index 0 (midnight of the start day), needed to
|
|
|
|
|
|
# turn decoded start slots into absolute local timestamps.
|
|
|
|
|
|
self._slot0_datetime: Optional[Any] = None
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
# Create Simulation
|
|
|
|
|
|
self.simulation = GeneticSimulation()
|
|
|
|
|
|
|
2026-07-12 09:01:11 +02:00
|
|
|
|
def _direct_marketing_enabled(self) -> bool:
|
|
|
|
|
|
"""Return whether direct marketing mode is enabled in configuration."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return bool(self.config.feedintariff.direct_marketing_enabled)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
def _appliance_horizon_end_slot(self) -> int:
|
|
|
|
|
|
"""Exclusive upper slot bound for appliance runs (end of horizon).
|
|
|
|
|
|
|
|
|
|
|
|
A run must complete within the optimization horizon. The horizon starts
|
|
|
|
|
|
at the current slot and lasts ``horizon_hours``; the bound is capped to
|
|
|
|
|
|
the total slot grid.
|
|
|
|
|
|
"""
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
|
|
|
|
|
horizon_slots = self.config.optimization.horizon_hours * self.slots_per_hour
|
|
|
|
|
|
return min(self.total_slots, start_slot + horizon_slots)
|
|
|
|
|
|
|
|
|
|
|
|
def _build_appliance_layout(
|
|
|
|
|
|
self, appliances: list[HomeAppliance], slot0_datetime: Any
|
|
|
|
|
|
) -> ApplianceGeneLayout:
|
|
|
|
|
|
"""Compute the appliance genome layout from the configured consumers.
|
|
|
|
|
|
|
|
|
|
|
|
For each appliance the allowed start slots are computed once. ONCE
|
|
|
|
|
|
appliances get a single gene; DAILY appliances get one gene per local
|
|
|
|
|
|
calendar day that still has at least one complete allowed run.
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ValueError: If a ONCE appliance has no valid start within the horizon.
|
|
|
|
|
|
"""
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
|
|
|
|
|
horizon_end_slot = self._appliance_horizon_end_slot()
|
|
|
|
|
|
genes: list[ApplianceGeneSlot] = []
|
|
|
|
|
|
gene_index = 0
|
|
|
|
|
|
for appliance_index, appliance in enumerate(appliances):
|
|
|
|
|
|
allowed = appliance.allowed_start_slots(
|
|
|
|
|
|
slot0_datetime=slot0_datetime,
|
|
|
|
|
|
earliest_slot=start_slot,
|
|
|
|
|
|
horizon_end_slot=horizon_end_slot,
|
|
|
|
|
|
)
|
|
|
|
|
|
if appliance.schedule_mode == ConsumerScheduleMode.ONCE:
|
|
|
|
|
|
if not allowed:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Home appliance '{appliance.device_id}' (ONCE) has no valid "
|
|
|
|
|
|
f"start slot within the optimization horizon and its time windows."
|
|
|
|
|
|
)
|
|
|
|
|
|
genes.append(
|
|
|
|
|
|
ApplianceGeneSlot(
|
|
|
|
|
|
gene_index=gene_index,
|
|
|
|
|
|
appliance_index=appliance_index,
|
|
|
|
|
|
device_id=appliance.device_id,
|
|
|
|
|
|
run_index=0,
|
|
|
|
|
|
run_date=None,
|
|
|
|
|
|
allowed_start_slots=allowed,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
gene_index += 1
|
|
|
|
|
|
else: # DAILY
|
|
|
|
|
|
by_date: "OrderedDict[Any, list[int]]" = OrderedDict()
|
|
|
|
|
|
for slot in allowed:
|
|
|
|
|
|
run_date = slot0_datetime.add(
|
|
|
|
|
|
seconds=slot * appliance.slot_interval_seconds
|
|
|
|
|
|
).date()
|
|
|
|
|
|
by_date.setdefault(run_date, []).append(slot)
|
|
|
|
|
|
if not by_date:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Home appliance '{}' (DAILY) has no valid start slot within the "
|
|
|
|
|
|
"horizon; no runs are scheduled.",
|
|
|
|
|
|
appliance.device_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
for run_index, (run_date, slots) in enumerate(by_date.items()):
|
|
|
|
|
|
genes.append(
|
|
|
|
|
|
ApplianceGeneSlot(
|
|
|
|
|
|
gene_index=gene_index,
|
|
|
|
|
|
appliance_index=appliance_index,
|
|
|
|
|
|
device_id=appliance.device_id,
|
|
|
|
|
|
run_index=run_index,
|
|
|
|
|
|
run_date=run_date,
|
|
|
|
|
|
allowed_start_slots=slots,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
gene_index += 1
|
|
|
|
|
|
return ApplianceGeneLayout(genes)
|
|
|
|
|
|
|
|
|
|
|
|
def _decode_appliance_starts(
|
|
|
|
|
|
self, appliance_gene_values: list[int]
|
|
|
|
|
|
) -> dict[int, list[int]]:
|
|
|
|
|
|
"""Map appliance gene values to absolute start slots per appliance.
|
|
|
|
|
|
|
|
|
|
|
|
Each gene value is an index into its gene's ``allowed_start_slots``; it is
|
|
|
|
|
|
clamped defensively so crossover artefacts can never index out of range.
|
|
|
|
|
|
"""
|
|
|
|
|
|
starts_per_appliance: dict[int, list[int]] = defaultdict(list)
|
|
|
|
|
|
for position, gene in enumerate(self.appliance_layout.genes):
|
|
|
|
|
|
allowed = gene.allowed_start_slots
|
|
|
|
|
|
if not allowed:
|
|
|
|
|
|
continue
|
|
|
|
|
|
value = int(appliance_gene_values[position])
|
|
|
|
|
|
value = min(max(value, 0), len(allowed) - 1)
|
|
|
|
|
|
starts_per_appliance[gene.appliance_index].append(allowed[value])
|
|
|
|
|
|
return starts_per_appliance
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_appliance_starts(self, appliance_gene_values: list[int]) -> None:
|
|
|
|
|
|
"""Build every appliance's load curve from the decoded starts."""
|
|
|
|
|
|
if not self.simulation.home_appliances:
|
|
|
|
|
|
return
|
|
|
|
|
|
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
|
|
|
|
|
|
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
|
|
|
|
|
|
appliance.build_load_curve(starts_per_appliance.get(appliance_index, []))
|
|
|
|
|
|
|
|
|
|
|
|
def _start_solution_matches_layout(self, start_solution: list[float]) -> bool:
|
|
|
|
|
|
"""Check that a start solution's appliance tail fits the current layout.
|
|
|
|
|
|
|
|
|
|
|
|
A length match alone is insufficient (two different layouts can share a
|
|
|
|
|
|
length), so every appliance gene value must be a valid index into its
|
|
|
|
|
|
gene's ``allowed_start_slots``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n_genes = self.appliance_layout.n_genes
|
|
|
|
|
|
if n_genes == 0:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if len(start_solution) < n_genes:
|
|
|
|
|
|
return False
|
|
|
|
|
|
tail = start_solution[-n_genes:]
|
|
|
|
|
|
for value, gene in zip(tail, self.appliance_layout.genes):
|
|
|
|
|
|
if not gene.allowed_start_slots:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not (0 <= int(value) < len(gene.allowed_start_slots)):
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-07-14 17:54:15 +02:00
|
|
|
|
def _ac_break_even_prices(
|
|
|
|
|
|
self,
|
|
|
|
|
|
prices_arr: Any,
|
|
|
|
|
|
load_arr: Any,
|
|
|
|
|
|
free_ac_wh: float,
|
|
|
|
|
|
) -> list[float]:
|
|
|
|
|
|
"""Best still-uncovered future price per potential AC-charge slot.
|
|
|
|
|
|
|
|
|
|
|
|
The AC-charge break-even penalty needs, for every potential charge slot,
|
|
|
|
|
|
the highest future price whose load is not already covered by the energy
|
|
|
|
|
|
that is in the battery at simulation start. Prices, loads and the free
|
|
|
|
|
|
battery energy are constant within one optimization run, so this table
|
|
|
|
|
|
is computed once per run and looked up in every fitness evaluation.
|
|
|
|
|
|
(Previously the future list was rebuilt and sorted per slot per
|
|
|
|
|
|
individual, which dominated the fitness runtime.) The loops replicate
|
|
|
|
|
|
the former inline computation exactly, keeping results bit-identical.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(prices_arr)
|
|
|
|
|
|
best_prices = [0.0] * n
|
|
|
|
|
|
for hour in range(n):
|
|
|
|
|
|
# Build list of (price, load_wh) for all future hours in the horizon
|
|
|
|
|
|
future = [(float(prices_arr[h]), float(load_arr[h])) for h in range(hour + 1, n)]
|
|
|
|
|
|
# Sort descending by price so we "use" the most expensive hours first
|
|
|
|
|
|
future.sort(key=lambda x: -x[0])
|
|
|
|
|
|
|
|
|
|
|
|
# Consume free PV energy against the highest-price future hours.
|
|
|
|
|
|
# The first uncovered (partially or fully) hour defines the best
|
|
|
|
|
|
# price still available for the new AC charge.
|
|
|
|
|
|
remaining_free = free_ac_wh
|
|
|
|
|
|
best_uncovered_price = 0.0
|
|
|
|
|
|
for fp, fl in future:
|
|
|
|
|
|
if remaining_free >= fl:
|
|
|
|
|
|
# Entire expensive hour is already covered by free PV energy
|
|
|
|
|
|
remaining_free -= fl
|
|
|
|
|
|
else:
|
|
|
|
|
|
# First hour not (fully) covered: this is where new charge goes
|
|
|
|
|
|
best_uncovered_price = fp
|
|
|
|
|
|
break
|
|
|
|
|
|
best_prices[hour] = best_uncovered_price
|
|
|
|
|
|
return best_prices
|
|
|
|
|
|
|
2026-07-12 09:01:11 +02:00
|
|
|
|
def _parameters_for_config(
|
|
|
|
|
|
self, parameters: GeneticOptimizationParameters
|
|
|
|
|
|
) -> GeneticOptimizationParameters:
|
|
|
|
|
|
"""Apply configuration-derived parameter overrides before optimization."""
|
|
|
|
|
|
if not self._direct_marketing_enabled():
|
|
|
|
|
|
return parameters
|
|
|
|
|
|
|
|
|
|
|
|
feed_in_tariff = parameters.ems.einspeiseverguetung_euro_pro_wh
|
|
|
|
|
|
if (
|
|
|
|
|
|
isinstance(feed_in_tariff, list)
|
|
|
|
|
|
and len(feed_in_tariff) == len(parameters.ems.strompreis_euro_pro_wh)
|
|
|
|
|
|
and len(set(feed_in_tariff)) > 1
|
|
|
|
|
|
):
|
|
|
|
|
|
return parameters
|
|
|
|
|
|
|
|
|
|
|
|
ems_parameters = parameters.ems.model_copy(
|
2026-07-14 17:00:07 +02:00
|
|
|
|
update={"einspeiseverguetung_euro_pro_wh": list(parameters.ems.strompreis_euro_pro_wh)},
|
2026-07-12 09:01:11 +02:00
|
|
|
|
deep=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return parameters.model_copy(update={"ems": ems_parameters}, deep=True)
|
|
|
|
|
|
|
2026-07-14 17:00:07 +02:00
|
|
|
|
def _parameters_for_slot_grid(
|
|
|
|
|
|
self, parameters: GeneticOptimizationParameters
|
|
|
|
|
|
) -> GeneticOptimizationParameters:
|
|
|
|
|
|
"""Normalize hourly or native-slot EMS input onto the optimization grid.
|
|
|
|
|
|
|
|
|
|
|
|
API clients historically provide one value per prediction hour. At a
|
|
|
|
|
|
sub-hourly interval, energy quantities are distributed across the slots
|
|
|
|
|
|
while price quantities are held constant. Inputs already matching the
|
|
|
|
|
|
native slot grid are preserved exactly. Any other length is ambiguous and
|
|
|
|
|
|
rejected instead of silently shortening the simulation horizon.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def normalize(values: list[float], name: str, *, energy: bool) -> list[float]:
|
|
|
|
|
|
value_count = len(values)
|
|
|
|
|
|
if value_count == self.total_slots:
|
|
|
|
|
|
return list(values)
|
|
|
|
|
|
if value_count != self.config.prediction.hours:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{name} has {value_count} values; expected either "
|
|
|
|
|
|
f"{self.config.prediction.hours} hourly values or "
|
|
|
|
|
|
f"{self.total_slots} optimization-slot values."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
normalized = np.repeat(np.asarray(values, dtype=float), self.slots_per_hour)
|
|
|
|
|
|
if energy:
|
|
|
|
|
|
normalized /= self.slots_per_hour
|
|
|
|
|
|
return normalized.tolist()
|
|
|
|
|
|
|
|
|
|
|
|
ems = parameters.ems
|
|
|
|
|
|
feed_in_tariff = ems.einspeiseverguetung_euro_pro_wh
|
|
|
|
|
|
if isinstance(feed_in_tariff, list):
|
|
|
|
|
|
normalized_feed_in_tariff: list[float] | float = normalize(
|
|
|
|
|
|
feed_in_tariff,
|
|
|
|
|
|
"einspeiseverguetung_euro_pro_wh",
|
|
|
|
|
|
energy=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
normalized_feed_in_tariff = [float(feed_in_tariff)] * self.total_slots
|
|
|
|
|
|
|
|
|
|
|
|
normalized_ems = ems.model_copy(
|
|
|
|
|
|
update={
|
|
|
|
|
|
"pv_prognose_wh": normalize(ems.pv_prognose_wh, "pv_prognose_wh", energy=True),
|
|
|
|
|
|
"gesamtlast": normalize(ems.gesamtlast, "gesamtlast", energy=True),
|
|
|
|
|
|
"strompreis_euro_pro_wh": normalize(
|
|
|
|
|
|
ems.strompreis_euro_pro_wh,
|
|
|
|
|
|
"strompreis_euro_pro_wh",
|
|
|
|
|
|
energy=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
"einspeiseverguetung_euro_pro_wh": normalized_feed_in_tariff,
|
|
|
|
|
|
},
|
|
|
|
|
|
deep=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
temperature_forecast = parameters.temperature_forecast
|
|
|
|
|
|
if temperature_forecast is not None:
|
|
|
|
|
|
if len(temperature_forecast) == self.config.prediction.hours:
|
|
|
|
|
|
temperature_forecast = [
|
|
|
|
|
|
value for value in temperature_forecast for _ in range(self.slots_per_hour)
|
|
|
|
|
|
]
|
|
|
|
|
|
elif len(temperature_forecast) != self.total_slots:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"temperature_forecast has {len(temperature_forecast)} values; expected "
|
|
|
|
|
|
f"either {self.config.prediction.hours} hourly values or "
|
|
|
|
|
|
f"{self.total_slots} optimization-slot values."
|
|
|
|
|
|
)
|
|
|
|
|
|
return parameters.model_copy(
|
|
|
|
|
|
update={"ems": normalized_ems, "temperature_forecast": temperature_forecast},
|
|
|
|
|
|
deep=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
def _start_solution_for_slot_grid(self, start_solution: list[float]) -> list[float]:
|
|
|
|
|
|
"""Expand a legacy hourly genome to the configured slot grid when possible.
|
|
|
|
|
|
|
|
|
|
|
|
Only the battery and EV parts are grid-expanded. The appliance start
|
|
|
|
|
|
genes are indices into interval-dependent allowed-start lists, so they
|
|
|
|
|
|
are copied verbatim and validated later against the current layout
|
|
|
|
|
|
(incompatible tails cause the whole start solution to be discarded).
|
|
|
|
|
|
"""
|
|
|
|
|
|
n_appliance_genes = self.appliance_layout.n_genes
|
|
|
|
|
|
expected_length = self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes
|
|
|
|
|
|
hourly_length = (
|
|
|
|
|
|
self.config.prediction.hours * (2 if self.optimize_ev else 1) + n_appliance_genes
|
|
|
|
|
|
)
|
2026-07-14 17:00:07 +02:00
|
|
|
|
|
|
|
|
|
|
if len(start_solution) == expected_length or self.slots_per_hour == 1:
|
|
|
|
|
|
return list(start_solution)
|
|
|
|
|
|
if len(start_solution) != hourly_length:
|
|
|
|
|
|
return list(start_solution)
|
|
|
|
|
|
|
|
|
|
|
|
battery_end = self.config.prediction.hours
|
|
|
|
|
|
migrated = np.repeat(start_solution[:battery_end], self.slots_per_hour).tolist()
|
|
|
|
|
|
if self.optimize_ev:
|
|
|
|
|
|
ev_end = battery_end + self.config.prediction.hours
|
|
|
|
|
|
migrated.extend(
|
|
|
|
|
|
np.repeat(start_solution[battery_end:ev_end], self.slots_per_hour).tolist()
|
|
|
|
|
|
)
|
2026-07-15 14:19:46 +02:00
|
|
|
|
if n_appliance_genes > 0:
|
|
|
|
|
|
migrated.extend(list(start_solution[-n_appliance_genes:]))
|
2026-07-14 17:00:07 +02:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"Expanded hourly start_solution from {} to {} slot values.",
|
|
|
|
|
|
hourly_length,
|
|
|
|
|
|
expected_length,
|
|
|
|
|
|
)
|
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
2024-10-22 10:29:57 +02:00
|
|
|
|
def decode_charge_discharge(
|
2024-12-19 14:45:20 +01:00
|
|
|
|
self, discharge_hours_bin: np.ndarray
|
2026-07-12 09:01:11 +02:00
|
|
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
|
|
|
|
"""Decode the input array into charge, self-consumption discharge and export arrays."""
|
2024-11-26 22:28:05 +01:00
|
|
|
|
discharge_hours_bin_np = np.array(discharge_hours_bin)
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Battery AC charge uses its own charge-level list (bat_possible_charge_values).
|
|
|
|
|
|
len_bat = len(self.bat_possible_charge_values)
|
2024-10-14 10:10:12 +02:00
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Categorization (using battery charge levels):
|
|
|
|
|
|
# Idle: 0 .. len_bat-1
|
|
|
|
|
|
# Discharge: len_bat .. 2*len_bat - 1
|
|
|
|
|
|
# AC Charge: 2*len_bat .. 3*len_bat - 1 (maps to bat_possible_charge_values)
|
|
|
|
|
|
# DC optional: 3*len_bat (not allowed), 3*len_bat + 1 (allowed)
|
2026-07-12 09:01:11 +02:00
|
|
|
|
# Grid export: next state, if direct marketing/export optimization is enabled
|
2024-10-16 15:40:04 +02:00
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# Idle states
|
2026-03-17 12:41:15 +01:00
|
|
|
|
idle_mask = (discharge_hours_bin_np >= 0) & (discharge_hours_bin_np < len_bat)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
|
|
|
|
|
# Discharge states
|
2026-03-17 12:41:15 +01:00
|
|
|
|
discharge_mask = (discharge_hours_bin_np >= len_bat) & (
|
|
|
|
|
|
discharge_hours_bin_np < 2 * len_bat
|
|
|
|
|
|
)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
|
|
|
|
|
# AC states
|
2026-03-17 12:41:15 +01:00
|
|
|
|
ac_mask = (discharge_hours_bin_np >= 2 * len_bat) & (discharge_hours_bin_np < 3 * len_bat)
|
|
|
|
|
|
ac_indices = (discharge_hours_bin_np[ac_mask] - 2 * len_bat).astype(int)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
|
|
|
|
|
# DC states (if enabled)
|
2024-10-20 18:18:06 +02:00
|
|
|
|
if self.optimize_dc_charge:
|
2026-03-17 12:41:15 +01:00
|
|
|
|
dc_not_allowed_state = 3 * len_bat
|
|
|
|
|
|
dc_allowed_state = 3 * len_bat + 1
|
2024-12-15 15:32:58 +01:00
|
|
|
|
dc_charge = np.where(discharge_hours_bin_np == dc_allowed_state, 1, 0)
|
2024-10-20 18:18:06 +02:00
|
|
|
|
else:
|
2024-12-15 15:32:58 +01:00
|
|
|
|
dc_charge = np.ones_like(discharge_hours_bin_np, dtype=float)
|
2024-10-16 15:40:04 +02:00
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# Generate the result arrays
|
|
|
|
|
|
discharge = np.zeros_like(discharge_hours_bin_np, dtype=int)
|
|
|
|
|
|
discharge[discharge_mask] = 1 # Set Discharge states to 1
|
|
|
|
|
|
|
|
|
|
|
|
ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float)
|
2026-03-17 12:41:15 +01:00
|
|
|
|
ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices]
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
2026-07-12 09:01:11 +02:00
|
|
|
|
battery_grid_export = np.zeros_like(discharge_hours_bin_np, dtype=int)
|
|
|
|
|
|
if self.optimize_battery_grid_export:
|
|
|
|
|
|
grid_export_state = 3 * len_bat + (2 if self.optimize_dc_charge else 0)
|
|
|
|
|
|
battery_grid_export = np.where(discharge_hours_bin_np == grid_export_state, 1, 0)
|
|
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# Idle is just 0, already default.
|
2024-10-16 15:40:04 +02:00
|
|
|
|
|
2026-07-12 09:01:11 +02:00
|
|
|
|
return ac_charge, dc_charge, discharge, battery_grid_export
|
2024-10-16 15:40:04 +02:00
|
|
|
|
|
2024-11-26 22:28:05 +01:00
|
|
|
|
def mutate(self, individual: list[int]) -> tuple[list[int]]:
|
2024-12-15 15:32:58 +01:00
|
|
|
|
"""Custom mutation function for the individual."""
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Calculate the number of states using battery charge levels
|
|
|
|
|
|
len_bat = len(self.bat_possible_charge_values)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
if self.optimize_dc_charge:
|
2026-03-17 12:41:15 +01:00
|
|
|
|
total_states = 3 * len_bat + 2
|
2024-12-15 15:32:58 +01:00
|
|
|
|
else:
|
2026-03-17 12:41:15 +01:00
|
|
|
|
total_states = 3 * len_bat
|
2026-07-12 09:01:11 +02:00
|
|
|
|
if self.optimize_battery_grid_export:
|
|
|
|
|
|
total_states += 1
|
2024-11-10 23:27:52 +01:00
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# 1. Mutating the charge_discharge part
|
2026-06-28 15:45:26 +00:00
|
|
|
|
charge_discharge_part = individual[: self.total_slots]
|
2024-10-22 10:29:57 +02:00
|
|
|
|
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
|
|
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
|
|
|
|
|
|
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
|
2026-06-28 15:45:26 +00:00
|
|
|
|
individual[: self.total_slots] = charge_discharge_mutated
|
2024-10-16 15:40:04 +02:00
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# 2. Mutating the EV charge part, if active
|
2024-10-14 10:46:14 +02:00
|
|
|
|
if self.optimize_ev:
|
2026-06-28 15:45:26 +00:00
|
|
|
|
ev_charge_part = individual[self.total_slots : self.total_slots * 2]
|
2024-10-22 10:29:57 +02:00
|
|
|
|
(ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part)
|
2026-06-28 15:45:26 +00:00
|
|
|
|
ev_charge_part_mutated[self.total_slots - self.fixed_eauto_hours :] = [
|
2025-01-18 14:26:34 +01:00
|
|
|
|
0
|
|
|
|
|
|
] * self.fixed_eauto_hours
|
2026-06-28 15:45:26 +00:00
|
|
|
|
individual[self.total_slots : self.total_slots * 2] = ev_charge_part_mutated
|
2024-10-14 10:46:14 +02:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# 3. Mutating the appliance start genes. Each gene is an index into its
|
|
|
|
|
|
# own allowed_start_slots list, so the redraw stays within valid range.
|
|
|
|
|
|
n_appliance_genes = self.appliance_layout.n_genes
|
|
|
|
|
|
if n_appliance_genes > 0:
|
|
|
|
|
|
base = len(individual) - n_appliance_genes
|
|
|
|
|
|
appliance_mutation_probability = 0.2
|
|
|
|
|
|
for position, gene in enumerate(self.appliance_layout.genes):
|
|
|
|
|
|
if random.random() < appliance_mutation_probability: # noqa: S311
|
|
|
|
|
|
upper = len(gene.allowed_start_slots) - 1
|
|
|
|
|
|
individual[base + position] = random.randint(0, upper) # noqa: S311
|
2024-10-14 10:46:14 +02:00
|
|
|
|
|
|
|
|
|
|
return (individual,)
|
|
|
|
|
|
|
|
|
|
|
|
# Method to create an individual based on the conditions
|
2024-11-26 22:28:05 +01:00
|
|
|
|
def create_individual(self) -> list[int]:
|
2024-10-14 10:46:14 +02:00
|
|
|
|
# Start with discharge states for the individual
|
2024-10-22 10:29:57 +02:00
|
|
|
|
individual_components = [
|
2026-06-28 15:45:26 +00:00
|
|
|
|
self.toolbox.attr_discharge_state() for _ in range(self.total_slots)
|
2024-10-22 10:29:57 +02:00
|
|
|
|
]
|
2024-10-14 10:46:14 +02:00
|
|
|
|
|
|
|
|
|
|
# Add EV charge index values if optimize_ev is True
|
|
|
|
|
|
if self.optimize_ev:
|
2024-10-22 10:29:57 +02:00
|
|
|
|
individual_components += [
|
2026-06-28 15:45:26 +00:00
|
|
|
|
self.toolbox.attr_ev_charge_index() for _ in range(self.total_slots)
|
2024-10-22 10:29:57 +02:00
|
|
|
|
]
|
2024-10-14 10:46:14 +02:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Add one appliance start gene per scheduled run (index into that run's
|
|
|
|
|
|
# allowed_start_slots). No draws happen when there are no appliances, so
|
|
|
|
|
|
# the battery/EV-only genome is unchanged.
|
|
|
|
|
|
for gene in self.appliance_layout.genes:
|
|
|
|
|
|
individual_components.append(random.randint(0, len(gene.allowed_start_slots) - 1)) # noqa: S311
|
2024-10-14 10:46:14 +02:00
|
|
|
|
|
|
|
|
|
|
return creator.Individual(individual_components)
|
2024-10-14 10:10:12 +02:00
|
|
|
|
|
2024-12-19 14:45:20 +01:00
|
|
|
|
def merge_individual(
|
|
|
|
|
|
self,
|
|
|
|
|
|
discharge_hours_bin: np.ndarray,
|
|
|
|
|
|
eautocharge_hours_index: Optional[np.ndarray],
|
2026-07-15 14:19:46 +02:00
|
|
|
|
appliance_gene_values: Optional[list[int]],
|
2024-12-19 14:45:20 +01:00
|
|
|
|
) -> list[int]:
|
|
|
|
|
|
"""Merge the individual components back into a single solution list.
|
|
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
|
discharge_hours_bin (np.ndarray): Binary discharge hours.
|
|
|
|
|
|
eautocharge_hours_index (Optional[np.ndarray]): EV charge hours as integers, or None.
|
2026-07-15 14:19:46 +02:00
|
|
|
|
appliance_gene_values (Optional[list[int]]): One index per appliance
|
|
|
|
|
|
start gene (into the gene's allowed_start_slots), or None.
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
list[int]: The merged individual solution as a list of integers.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Start with the discharge hours
|
|
|
|
|
|
individual = discharge_hours_bin.tolist()
|
|
|
|
|
|
|
|
|
|
|
|
# Add EV charge hours if applicable
|
|
|
|
|
|
if self.optimize_ev and eautocharge_hours_index is not None:
|
|
|
|
|
|
individual.extend(eautocharge_hours_index.tolist())
|
|
|
|
|
|
elif self.optimize_ev:
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# optimize_ev active but no EV data present: pad with zeros
|
2026-06-28 15:45:26 +00:00
|
|
|
|
individual.extend([0] * self.total_slots)
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Add appliance start genes (one index per scheduled run).
|
|
|
|
|
|
n_appliance_genes = self.appliance_layout.n_genes
|
|
|
|
|
|
if n_appliance_genes > 0:
|
|
|
|
|
|
if appliance_gene_values is not None:
|
|
|
|
|
|
individual.extend(int(value) for value in appliance_gene_values)
|
|
|
|
|
|
else:
|
|
|
|
|
|
individual.extend([0] * n_appliance_genes)
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
|
|
|
|
|
return individual
|
|
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
def split_individual(
|
2024-12-19 14:45:20 +01:00
|
|
|
|
self, individual: list[int]
|
2026-07-15 14:19:46 +02:00
|
|
|
|
) -> tuple[np.ndarray, Optional[np.ndarray], list[int]]:
|
2024-11-10 23:27:52 +01:00
|
|
|
|
"""Split the individual solution into its components.
|
|
|
|
|
|
|
|
|
|
|
|
Components:
|
2024-12-19 14:45:20 +01:00
|
|
|
|
1. Discharge hours (binary as int NumPy array),
|
|
|
|
|
|
2. Electric vehicle charge hours (float as int NumPy array, if applicable),
|
2026-07-15 14:19:46 +02:00
|
|
|
|
3. Appliance start genes (list of indices, one per scheduled run).
|
2024-09-30 08:23:38 +02:00
|
|
|
|
"""
|
2024-12-19 14:45:20 +01:00
|
|
|
|
# Discharge hours as a NumPy array of ints
|
2026-06-28 15:45:26 +00:00
|
|
|
|
discharge_hours_bin = np.array(individual[: self.total_slots], dtype=int)
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
|
|
|
|
|
# EV charge hours as a NumPy array of ints (if optimize_ev is True)
|
2024-11-26 22:28:05 +01:00
|
|
|
|
eautocharge_hours_index = (
|
2025-01-19 14:54:15 +01:00
|
|
|
|
# append ev charging states to individual
|
2024-12-19 14:45:20 +01:00
|
|
|
|
np.array(
|
2026-06-28 15:45:26 +00:00
|
|
|
|
individual[self.total_slots : self.total_slots * 2],
|
2024-12-19 14:45:20 +01:00
|
|
|
|
dtype=int,
|
|
|
|
|
|
)
|
2024-10-22 10:29:57 +02:00
|
|
|
|
if self.optimize_ev
|
2024-10-16 15:40:04 +02:00
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Appliance start genes are the trailing entries of the genome.
|
|
|
|
|
|
n_appliance_genes = self.appliance_layout.n_genes
|
|
|
|
|
|
if n_appliance_genes > 0:
|
|
|
|
|
|
appliance_gene_values = [int(value) for value in individual[-n_appliance_genes:]]
|
|
|
|
|
|
else:
|
|
|
|
|
|
appliance_gene_values = []
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
return discharge_hours_bin, eautocharge_hours_index, appliance_gene_values
|
2024-08-24 10:22:49 +02:00
|
|
|
|
|
2026-07-16 09:56:50 +02:00
|
|
|
|
def _repair_ev_charge_at_full_soc(
|
|
|
|
|
|
self,
|
|
|
|
|
|
individual: list[int],
|
|
|
|
|
|
simulation_result: dict[str, Any],
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Remove EV charging genes in slots that begin at full SoC.
|
|
|
|
|
|
|
|
|
|
|
|
The repair is deliberately separated from fitness calculation. Callers
|
|
|
|
|
|
must re-simulate after a change so the individual's genome, simulation
|
|
|
|
|
|
state and assigned fitness always describe the same schedule.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.optimize_ev or not self.ev_possible_charge_values:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
zero_charge_index = min(
|
|
|
|
|
|
range(len(self.ev_possible_charge_values)),
|
|
|
|
|
|
key=lambda index: abs(self.ev_possible_charge_values[index]),
|
|
|
|
|
|
)
|
|
|
|
|
|
if abs(self.ev_possible_charge_values[zero_charge_index]) > 1e-12:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
_, ev_charge_indices, _ = self.split_individual(individual)
|
|
|
|
|
|
if ev_charge_indices is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
ev_soc = np.asarray(simulation_result.get("EAuto_SoC_pro_Stunde", []), dtype=float)
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
|
|
|
|
|
result_slots = min(ev_soc.size, self.total_slots - start_slot)
|
|
|
|
|
|
if result_slots <= 0:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
for offset in range(result_slots):
|
|
|
|
|
|
slot = start_slot + offset
|
|
|
|
|
|
charge_index = int(ev_charge_indices[slot])
|
|
|
|
|
|
if (
|
|
|
|
|
|
ev_soc[offset] >= 100.0 - 1e-9
|
|
|
|
|
|
and self.ev_possible_charge_values[charge_index] > 0.0
|
|
|
|
|
|
):
|
|
|
|
|
|
ev_charge_indices[slot] = zero_charge_index
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
|
|
|
|
|
|
if changed:
|
|
|
|
|
|
battery_genes, _, appliance_genes = self.split_individual(individual)
|
|
|
|
|
|
individual[:] = self.merge_individual(
|
|
|
|
|
|
battery_genes,
|
|
|
|
|
|
ev_charge_indices,
|
|
|
|
|
|
appliance_genes,
|
|
|
|
|
|
)
|
|
|
|
|
|
return changed
|
|
|
|
|
|
|
|
|
|
|
|
def _heuristic_ev_schedule(self, *, prefer_pv: bool) -> list[int]:
|
|
|
|
|
|
"""Build a low-cost EV schedule that reaches the configured minimum SoC."""
|
|
|
|
|
|
if not self.optimize_ev or not self.ev_possible_charge_values:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
zero_index = min(
|
|
|
|
|
|
range(len(self.ev_possible_charge_values)),
|
|
|
|
|
|
key=lambda index: abs(self.ev_possible_charge_values[index]),
|
|
|
|
|
|
)
|
|
|
|
|
|
schedule = [zero_index] * self.total_slots
|
|
|
|
|
|
ev = self.simulation.ev
|
|
|
|
|
|
if ev is None:
|
|
|
|
|
|
return schedule
|
|
|
|
|
|
|
|
|
|
|
|
required_stored_wh = max(
|
|
|
|
|
|
ev.min_soc_wh
|
|
|
|
|
|
- ev.capacity_wh * ev.initial_soc_percentage / 100.0,
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
if required_stored_wh <= 0.0:
|
|
|
|
|
|
return schedule
|
|
|
|
|
|
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
|
|
|
|
|
end_slot = max(start_slot, self.total_slots - self.fixed_eauto_hours)
|
|
|
|
|
|
prices = np.asarray(self.simulation.elect_price_hourly, dtype=float)
|
|
|
|
|
|
feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float)
|
|
|
|
|
|
pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float)
|
|
|
|
|
|
load = np.asarray(self.simulation.load_energy_array, dtype=float)
|
|
|
|
|
|
|
|
|
|
|
|
def marginal_cost(slot: int) -> tuple[float, float]:
|
|
|
|
|
|
surplus = pv[slot] - load[slot]
|
|
|
|
|
|
if prefer_pv and surplus > 0.0:
|
|
|
|
|
|
return (float(feed_in[slot]), -float(surplus))
|
|
|
|
|
|
return (float(prices[slot]), -float(surplus))
|
|
|
|
|
|
|
|
|
|
|
|
candidates = sorted(range(start_slot, end_slot), key=marginal_cost)
|
|
|
|
|
|
positive_rates = sorted(
|
|
|
|
|
|
(
|
|
|
|
|
|
(rate, index)
|
|
|
|
|
|
for index, rate in enumerate(self.ev_possible_charge_values)
|
|
|
|
|
|
if rate > 0.0
|
|
|
|
|
|
),
|
|
|
|
|
|
key=lambda item: item[0],
|
|
|
|
|
|
)
|
|
|
|
|
|
if not positive_rates:
|
|
|
|
|
|
return schedule
|
|
|
|
|
|
|
|
|
|
|
|
max_stored_wh = (
|
|
|
|
|
|
ev.max_charge_power_w
|
|
|
|
|
|
* self.slot_duration_h
|
|
|
|
|
|
* ev.charging_efficiency
|
|
|
|
|
|
)
|
|
|
|
|
|
remaining_wh = required_stored_wh
|
|
|
|
|
|
for slot in candidates:
|
|
|
|
|
|
required_rate = remaining_wh / max(max_stored_wh, 1e-9)
|
|
|
|
|
|
rate, rate_index = next(
|
|
|
|
|
|
(item for item in positive_rates if item[0] >= required_rate),
|
|
|
|
|
|
positive_rates[-1],
|
|
|
|
|
|
)
|
|
|
|
|
|
schedule[slot] = rate_index
|
|
|
|
|
|
remaining_wh -= max_stored_wh * rate
|
|
|
|
|
|
if remaining_wh <= 1e-9:
|
|
|
|
|
|
break
|
|
|
|
|
|
return schedule
|
|
|
|
|
|
|
|
|
|
|
|
def _heuristic_appliance_genes(self) -> list[int]:
|
|
|
|
|
|
"""Choose low-opportunity-cost starts for flexible appliances."""
|
|
|
|
|
|
if self.appliance_layout.n_genes == 0:
|
|
|
|
|
|
return []
|
|
|
|
|
|
prices = np.asarray(self.simulation.elect_price_hourly, dtype=float)
|
|
|
|
|
|
feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float)
|
|
|
|
|
|
pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float)
|
|
|
|
|
|
load = np.asarray(self.simulation.load_energy_array, dtype=float)
|
|
|
|
|
|
genes: list[int] = []
|
|
|
|
|
|
for gene in self.appliance_layout.genes:
|
|
|
|
|
|
def opportunity_cost(position: int) -> float:
|
|
|
|
|
|
slot = gene.allowed_start_slots[position]
|
|
|
|
|
|
return float(feed_in[slot] if pv[slot] > load[slot] else prices[slot])
|
|
|
|
|
|
|
|
|
|
|
|
genes.append(min(range(len(gene.allowed_start_slots)), key=opportunity_cost))
|
|
|
|
|
|
return genes
|
|
|
|
|
|
|
|
|
|
|
|
def _educated_guess_individuals(self) -> list[list[int]]:
|
|
|
|
|
|
"""Create diverse domain-informed candidates for the initial population."""
|
|
|
|
|
|
slots = self.total_slots
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
|
|
|
|
|
len_bat = len(self.bat_possible_charge_values)
|
|
|
|
|
|
idle_state = 0
|
|
|
|
|
|
discharge_state = len_bat
|
|
|
|
|
|
ac_charge_state = 3 * len_bat - 1
|
|
|
|
|
|
dc_allowed_state = 3 * len_bat + 1
|
|
|
|
|
|
export_state = 3 * len_bat + (2 if self.optimize_dc_charge else 0)
|
|
|
|
|
|
|
|
|
|
|
|
prices = np.asarray(self.simulation.elect_price_hourly, dtype=float)
|
|
|
|
|
|
feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float)
|
|
|
|
|
|
pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float)
|
|
|
|
|
|
load = np.asarray(self.simulation.load_energy_array, dtype=float)
|
|
|
|
|
|
future = slice(start_slot, slots)
|
|
|
|
|
|
future_prices = prices[future]
|
|
|
|
|
|
future_feed_in = feed_in[future]
|
|
|
|
|
|
high_import_price = float(np.quantile(future_prices, 0.70))
|
|
|
|
|
|
low_import_price = float(np.quantile(future_prices, 0.25))
|
|
|
|
|
|
|
|
|
|
|
|
ev_price = self._heuristic_ev_schedule(prefer_pv=False)
|
|
|
|
|
|
ev_pv = self._heuristic_ev_schedule(prefer_pv=True)
|
|
|
|
|
|
appliance_genes = self._heuristic_appliance_genes()
|
|
|
|
|
|
|
|
|
|
|
|
def compose(battery_genes: list[int], ev_genes: list[int]) -> list[int]:
|
|
|
|
|
|
individual = list(battery_genes)
|
|
|
|
|
|
if self.optimize_ev:
|
|
|
|
|
|
individual.extend(ev_genes)
|
|
|
|
|
|
individual.extend(appliance_genes)
|
|
|
|
|
|
return individual
|
|
|
|
|
|
|
|
|
|
|
|
guesses: list[list[int]] = []
|
|
|
|
|
|
|
|
|
|
|
|
# Baseline and self-consumption candidates are useful even without
|
|
|
|
|
|
# direct marketing and anchor the population with feasible schedules.
|
|
|
|
|
|
guesses.append(compose([idle_state] * slots, ev_price))
|
|
|
|
|
|
self_consumption = [idle_state] * slots
|
|
|
|
|
|
for slot in range(start_slot, slots):
|
|
|
|
|
|
if self.optimize_dc_charge and pv[slot] > load[slot]:
|
|
|
|
|
|
self_consumption[slot] = dc_allowed_state
|
|
|
|
|
|
elif prices[slot] >= high_import_price and load[slot] > pv[slot]:
|
|
|
|
|
|
self_consumption[slot] = discharge_state
|
|
|
|
|
|
guesses.append(compose(self_consumption, ev_pv))
|
|
|
|
|
|
|
|
|
|
|
|
# Direct marketing candidates export only in the relatively expensive
|
|
|
|
|
|
# feed-in slots. At low tariffs PV is preferentially stored instead.
|
|
|
|
|
|
if self.optimize_battery_grid_export and future_feed_in.size:
|
|
|
|
|
|
feed_spread = float(np.ptp(future_feed_in))
|
|
|
|
|
|
for quantile in self.EDUCATED_GUESS_EXPORT_QUANTILES:
|
|
|
|
|
|
export_threshold = float(np.quantile(future_feed_in, quantile))
|
|
|
|
|
|
direct_marketing = [idle_state] * slots
|
|
|
|
|
|
for slot in range(start_slot, slots):
|
|
|
|
|
|
high_feed_in = (
|
|
|
|
|
|
feed_spread > 1e-12
|
|
|
|
|
|
and feed_in[slot] > 0.0
|
|
|
|
|
|
and feed_in[slot] >= export_threshold
|
|
|
|
|
|
)
|
|
|
|
|
|
if high_feed_in:
|
|
|
|
|
|
direct_marketing[slot] = export_state
|
|
|
|
|
|
elif self.optimize_dc_charge and pv[slot] > load[slot]:
|
|
|
|
|
|
direct_marketing[slot] = dc_allowed_state
|
|
|
|
|
|
elif prices[slot] >= high_import_price and load[slot] > pv[slot]:
|
|
|
|
|
|
direct_marketing[slot] = discharge_state
|
|
|
|
|
|
guesses.append(compose(direct_marketing, ev_pv))
|
|
|
|
|
|
|
|
|
|
|
|
inverter = self.simulation.inverter
|
|
|
|
|
|
if inverter is not None and (
|
|
|
|
|
|
inverter.max_ac_charge_power_w is None or inverter.max_ac_charge_power_w > 0
|
|
|
|
|
|
):
|
|
|
|
|
|
price_arbitrage = [idle_state] * slots
|
|
|
|
|
|
for slot in range(start_slot, slots):
|
|
|
|
|
|
if prices[slot] <= low_import_price:
|
|
|
|
|
|
price_arbitrage[slot] = ac_charge_state
|
|
|
|
|
|
elif prices[slot] >= high_import_price:
|
|
|
|
|
|
price_arbitrage[slot] = discharge_state
|
|
|
|
|
|
guesses.append(compose(price_arbitrage, ev_price))
|
|
|
|
|
|
|
|
|
|
|
|
unique: dict[tuple[int, ...], list[int]] = {}
|
|
|
|
|
|
for guess in guesses:
|
|
|
|
|
|
unique.setdefault(tuple(guess), guess)
|
|
|
|
|
|
return list(unique.values())
|
|
|
|
|
|
|
|
|
|
|
|
def _mutated_warm_start_neighbors(
|
|
|
|
|
|
self,
|
|
|
|
|
|
start_solution: list[float],
|
|
|
|
|
|
count: int,
|
|
|
|
|
|
) -> list[list[int]]:
|
|
|
|
|
|
"""Create unique local variants while preserving already elapsed slots."""
|
|
|
|
|
|
original = [int(value) for value in start_solution]
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
|
|
|
|
|
seen = {tuple(original)}
|
|
|
|
|
|
neighbors: list[list[int]] = []
|
|
|
|
|
|
for _ in range(max(count * 10, 1)):
|
|
|
|
|
|
neighbor = creator.Individual(original)
|
|
|
|
|
|
self.mutate(neighbor)
|
|
|
|
|
|
neighbor[:start_slot] = original[:start_slot]
|
|
|
|
|
|
if self.optimize_ev:
|
|
|
|
|
|
ev_start = self.total_slots
|
|
|
|
|
|
neighbor[ev_start : ev_start + start_slot] = original[
|
|
|
|
|
|
ev_start : ev_start + start_slot
|
|
|
|
|
|
]
|
|
|
|
|
|
key = tuple(int(value) for value in neighbor)
|
|
|
|
|
|
if key in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(key)
|
|
|
|
|
|
neighbors.append(list(key))
|
|
|
|
|
|
if len(neighbors) >= count:
|
|
|
|
|
|
break
|
|
|
|
|
|
return neighbors
|
|
|
|
|
|
|
2024-11-15 22:27:25 +01:00
|
|
|
|
def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> None:
|
2024-11-10 23:27:52 +01:00
|
|
|
|
"""Set up the DEAP environment with fitness and individual creation rules."""
|
2024-04-02 16:46:16 +02:00
|
|
|
|
self.opti_param = opti_param
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2024-12-15 15:32:58 +01:00
|
|
|
|
# Remove existing definitions if any
|
2024-10-04 03:11:24 +02:00
|
|
|
|
for attr in ["FitnessMin", "Individual"]:
|
|
|
|
|
|
if attr in creator.__dict__:
|
|
|
|
|
|
del creator.__dict__[attr]
|
2024-09-30 08:23:38 +02:00
|
|
|
|
|
2024-04-02 16:46:16 +02:00
|
|
|
|
creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
|
|
|
|
|
|
creator.create("Individual", list, fitness=creator.FitnessMin)
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2024-04-02 16:46:16 +02:00
|
|
|
|
self.toolbox = base.Toolbox()
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Battery state space uses bat_possible_charge_values; EV index space uses ev_possible_charge_values.
|
|
|
|
|
|
len_bat = len(self.bat_possible_charge_values)
|
|
|
|
|
|
len_ev = len(self.ev_possible_charge_values)
|
2024-10-20 18:18:06 +02:00
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Total battery/discharge states:
|
|
|
|
|
|
# Idle: len_bat states
|
|
|
|
|
|
# Discharge: len_bat states
|
|
|
|
|
|
# AC-Charge: len_bat states (maps to bat_possible_charge_values)
|
|
|
|
|
|
# With DC: + 2 additional states
|
2026-07-12 09:01:11 +02:00
|
|
|
|
# With battery grid export: + 1 additional state
|
2024-12-15 15:32:58 +01:00
|
|
|
|
if self.optimize_dc_charge:
|
2026-03-17 12:41:15 +01:00
|
|
|
|
total_states = 3 * len_bat + 2
|
2024-12-15 15:32:58 +01:00
|
|
|
|
else:
|
2026-03-17 12:41:15 +01:00
|
|
|
|
total_states = 3 * len_bat
|
2026-07-12 09:01:11 +02:00
|
|
|
|
if self.optimize_battery_grid_export:
|
|
|
|
|
|
total_states += 1
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
|
|
|
|
|
# State space: 0 .. (total_states - 1)
|
|
|
|
|
|
self.toolbox.register("attr_discharge_state", random.randint, 0, total_states - 1)
|
|
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# EV attributes (separate index space)
|
2024-10-14 10:10:12 +02:00
|
|
|
|
if self.optimize_ev:
|
2024-10-22 10:29:57 +02:00
|
|
|
|
self.toolbox.register(
|
2024-11-11 21:38:13 +01:00
|
|
|
|
"attr_ev_charge_index",
|
|
|
|
|
|
random.randint,
|
|
|
|
|
|
0,
|
2026-03-17 12:41:15 +01:00
|
|
|
|
len_ev - 1,
|
2024-10-22 10:29:57 +02:00
|
|
|
|
)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
2024-10-14 10:46:14 +02:00
|
|
|
|
self.toolbox.register("individual", self.create_individual)
|
2024-10-10 15:00:32 +02:00
|
|
|
|
self.toolbox.register("population", tools.initRepeat, list, self.toolbox.individual)
|
2024-04-01 14:11:38 +02:00
|
|
|
|
self.toolbox.register("mate", tools.cxTwoPoint)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Mutation operator for battery charge/discharge states
|
2026-07-14 17:00:07 +02:00
|
|
|
|
# Keep the expected number of mutated genes per hour stable when the
|
|
|
|
|
|
# interval becomes finer (0.2 hourly -> 0.05 on a quarter-hour grid).
|
|
|
|
|
|
mutation_probability = 0.2 / self.slots_per_hour
|
2024-12-15 15:32:58 +01:00
|
|
|
|
self.toolbox.register(
|
2026-07-14 17:00:07 +02:00
|
|
|
|
"mutate_charge_discharge",
|
|
|
|
|
|
tools.mutUniformInt,
|
|
|
|
|
|
low=0,
|
|
|
|
|
|
up=total_states - 1,
|
|
|
|
|
|
indpb=mutation_probability,
|
2024-12-15 15:32:58 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Mutation operator for EV states (separate index space)
|
2024-10-22 10:29:57 +02:00
|
|
|
|
self.toolbox.register(
|
|
|
|
|
|
"mutate_ev_charge_index",
|
|
|
|
|
|
tools.mutUniformInt,
|
|
|
|
|
|
low=0,
|
2026-03-17 12:41:15 +01:00
|
|
|
|
up=len_ev - 1,
|
2026-07-14 17:00:07 +02:00
|
|
|
|
indpb=mutation_probability,
|
2024-10-22 10:29:57 +02:00
|
|
|
|
)
|
2024-12-15 15:32:58 +01:00
|
|
|
|
|
|
|
|
|
|
# Custom mutate function remains unchanged
|
2024-10-14 10:46:14 +02:00
|
|
|
|
self.toolbox.register("mutate", self.mutate)
|
2024-10-03 11:05:44 +02:00
|
|
|
|
self.toolbox.register("select", tools.selTournament, tournsize=3)
|
|
|
|
|
|
|
2024-12-19 14:45:20 +01:00
|
|
|
|
def evaluate_inner(self, individual: list[int]) -> dict[str, Any]:
|
2024-11-10 23:27:52 +01:00
|
|
|
|
"""Simulates the energy management system (EMS) using the provided individual solution.
|
|
|
|
|
|
|
|
|
|
|
|
This is an internal function.
|
2024-10-04 03:11:24 +02:00
|
|
|
|
"""
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.simulation.reset()
|
2026-07-15 14:19:46 +02:00
|
|
|
|
discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = self.split_individual(
|
2024-10-10 15:00:32 +02:00
|
|
|
|
individual
|
2024-10-03 11:05:44 +02:00
|
|
|
|
)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Decode the appliance start genes and (re)build each appliance's load
|
|
|
|
|
|
# curve for this candidate solution.
|
|
|
|
|
|
self._apply_appliance_starts(appliance_gene_values)
|
2024-09-30 08:23:38 +02:00
|
|
|
|
|
2026-07-12 09:01:11 +02:00
|
|
|
|
ac_charge_hours, dc_charge_hours, discharge, battery_grid_export = (
|
|
|
|
|
|
self.decode_charge_discharge(discharge_hours_bin)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
)
|
2024-10-14 10:10:12 +02:00
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
self.simulation.bat_discharge_hours = discharge
|
2026-07-12 09:01:11 +02:00
|
|
|
|
self.simulation.bat_grid_export_hours = battery_grid_export
|
2024-10-20 18:18:06 +02:00
|
|
|
|
# Set DC charge hours only if DC optimization is enabled
|
|
|
|
|
|
if self.optimize_dc_charge:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
self.simulation.dc_charge_hours = dc_charge_hours
|
|
|
|
|
|
else:
|
2026-06-28 15:45:26 +00:00
|
|
|
|
self.simulation.dc_charge_hours = np.full(self.total_slots, 1)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
self.simulation.ac_charge_hours = ac_charge_hours
|
2024-10-14 10:46:14 +02:00
|
|
|
|
|
2024-11-26 22:28:05 +01:00
|
|
|
|
if eautocharge_hours_index is not None:
|
2024-12-15 14:40:03 +01:00
|
|
|
|
eautocharge_hours_float = np.array(
|
2025-10-28 02:50:31 +01:00
|
|
|
|
[self.ev_possible_charge_values[i] for i in eautocharge_hours_index],
|
2024-12-15 14:40:03 +01:00
|
|
|
|
float,
|
|
|
|
|
|
)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# discharge is set to 0 by default
|
|
|
|
|
|
self.simulation.ev_charge_hours = eautocharge_hours_float
|
2024-10-20 18:18:06 +02:00
|
|
|
|
else:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# discharge is set to 0 by default
|
2026-06-28 15:45:26 +00:00
|
|
|
|
self.simulation.ev_charge_hours = np.full(self.total_slots, 0)
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
2026-06-28 15:45:26 +00:00
|
|
|
|
# Do the simulation and return result. simulate()'s argument is a slot
|
|
|
|
|
|
# index into the prediction/charge arrays, not an hour-of-day, so pass
|
|
|
|
|
|
# the start_day_slot to keep sub-hourly runs aligned.
|
|
|
|
|
|
return self.simulation.simulate(self._start_day_slot())
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
def evaluate(
|
|
|
|
|
|
self,
|
2024-12-19 14:45:20 +01:00
|
|
|
|
individual: list[int],
|
2025-10-28 02:50:31 +01:00
|
|
|
|
parameters: GeneticOptimizationParameters,
|
2024-10-04 03:11:24 +02:00
|
|
|
|
start_hour: int,
|
|
|
|
|
|
worst_case: bool,
|
2024-12-19 14:45:20 +01:00
|
|
|
|
) -> tuple[float]:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
"""Evaluate the fitness score of a single individual in the DEAP genetic algorithm.
|
|
|
|
|
|
|
|
|
|
|
|
This method runs a simulation based on the provided individual genome and
|
|
|
|
|
|
optimization parameters. The resulting performance is converted into a
|
|
|
|
|
|
fitness score compatible with DEAP (i.e., returned as a 1-tuple).
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
individual (list[int]):
|
|
|
|
|
|
The genome representing one candidate solution.
|
|
|
|
|
|
parameters (GeneticOptimizationParameters):
|
|
|
|
|
|
Optimization parameters that influence simulation behavior,
|
|
|
|
|
|
constraints, and scoring logic.
|
|
|
|
|
|
start_hour (int):
|
|
|
|
|
|
The simulation start hour (0–23 or domain-specific).
|
|
|
|
|
|
Used to initialize time-based scheduling or constraints.
|
|
|
|
|
|
worst_case (bool):
|
|
|
|
|
|
If True, evaluates the solution under worst-case assumptions
|
|
|
|
|
|
(e.g., pessimistic forecasts or boundary conditions).
|
|
|
|
|
|
If False, uses nominal assumptions.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
tuple[float]:
|
|
|
|
|
|
A single-element tuple containing the computed fitness score.
|
|
|
|
|
|
Lower score is better: "FitnessMin".
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ValueError: If input arguments are invalid or the individual structure
|
|
|
|
|
|
is not compatible with the simulation.
|
|
|
|
|
|
RuntimeError: If the simulation fails or cannot produce results.
|
|
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
|
The resulting score should match DEAP's expected format: a tuple, even
|
|
|
|
|
|
if only a single scalar fitness value is returned.
|
|
|
|
|
|
"""
|
2024-09-30 08:23:38 +02:00
|
|
|
|
try:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
simulation_result = self.evaluate_inner(individual)
|
2026-07-16 09:56:50 +02:00
|
|
|
|
if self._repair_ev_charge_at_full_soc(individual, simulation_result):
|
|
|
|
|
|
simulation_result = self.evaluate_inner(individual)
|
|
|
|
|
|
except Exception:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# Return bad fitness score ("FitnessMin") in case of an exception
|
|
|
|
|
|
return (100000.0,)
|
2024-10-22 10:29:57 +02:00
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
gesamtbilanz = simulation_result["Gesamtbilanz_Euro"] * (-1.0 if worst_case else 1.0)
|
2024-10-22 10:29:57 +02:00
|
|
|
|
|
2024-12-25 19:12:38 +01:00
|
|
|
|
# New check: Activate discharge when battery SoC is 0
|
2025-01-13 21:47:58 +01:00
|
|
|
|
# battery_soc_per_hour = np.array(
|
|
|
|
|
|
# o.get("akku_soc_pro_stunde", [])
|
|
|
|
|
|
# ) # Example key for battery SoC
|
2024-12-25 19:12:38 +01:00
|
|
|
|
|
2025-01-13 21:47:58 +01:00
|
|
|
|
# if battery_soc_per_hour is not None:
|
|
|
|
|
|
# if battery_soc_per_hour is None or discharge_hours_bin is None:
|
|
|
|
|
|
# raise ValueError("battery_soc_per_hour or discharge_hours_bin is None")
|
|
|
|
|
|
# min_length = min(battery_soc_per_hour.size, discharge_hours_bin.size)
|
|
|
|
|
|
# battery_soc_per_hour_tail = battery_soc_per_hour[-min_length:]
|
|
|
|
|
|
# discharge_hours_bin_tail = discharge_hours_bin[-min_length:]
|
2025-01-18 14:26:34 +01:00
|
|
|
|
# len_ac = len(self.config.optimization.ev_available_charge_rates_percent)
|
2024-12-25 19:12:38 +01:00
|
|
|
|
|
2025-01-13 21:47:58 +01:00
|
|
|
|
# # # Find hours where battery SoC is 0
|
|
|
|
|
|
# # zero_soc_mask = battery_soc_per_hour_tail == 0
|
|
|
|
|
|
# # discharge_hours_bin_tail[zero_soc_mask] = (
|
|
|
|
|
|
# # len_ac + 2
|
|
|
|
|
|
# # ) # Activate discharge for these hours
|
2024-12-25 19:12:38 +01:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
# # When Battery SoC then set the Discharge randomly to 0 or 1. otherwise it's very
|
|
|
|
|
|
# # unlikely to get a state where a battery can store energy for a longer time
|
2025-01-13 21:47:58 +01:00
|
|
|
|
# # Find hours where battery SoC is 0
|
|
|
|
|
|
# zero_soc_mask = battery_soc_per_hour_tail == 0
|
|
|
|
|
|
# # discharge_hours_bin_tail[zero_soc_mask] = (
|
|
|
|
|
|
# # len_ac + 2
|
|
|
|
|
|
# # ) # Activate discharge for these hours
|
|
|
|
|
|
# set_to_len_ac_plus_2 = np.random.rand() < 0.5 # True mit 50% Wahrscheinlichkeit
|
|
|
|
|
|
|
|
|
|
|
|
# # Werte setzen basierend auf der zufälligen Entscheidung
|
|
|
|
|
|
# value_to_set = len_ac + 2 if set_to_len_ac_plus_2 else 0
|
|
|
|
|
|
# discharge_hours_bin_tail[zero_soc_mask] = value_to_set
|
|
|
|
|
|
|
|
|
|
|
|
# # Merge the updated discharge_hours_bin back into the individual
|
|
|
|
|
|
# adjusted_individual = self.merge_individual(
|
|
|
|
|
|
# discharge_hours_bin, eautocharge_hours_index, washingstart_int
|
|
|
|
|
|
# )
|
|
|
|
|
|
# individual[:] = adjusted_individual
|
2024-12-25 19:12:38 +01:00
|
|
|
|
|
|
|
|
|
|
# More metrics
|
2024-11-26 22:28:05 +01:00
|
|
|
|
individual.extra_data = ( # type: ignore[attr-defined]
|
2025-11-08 15:42:18 +01:00
|
|
|
|
simulation_result["Gesamtbilanz_Euro"],
|
|
|
|
|
|
simulation_result["Gesamt_Verluste"],
|
2025-10-28 02:50:31 +01:00
|
|
|
|
parameters.eauto.min_soc_percentage - self.simulation.ev.current_soc_percentage()
|
|
|
|
|
|
if parameters.eauto and self.simulation.ev
|
2024-11-26 22:28:05 +01:00
|
|
|
|
else 0,
|
2024-10-03 11:05:44 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
# Adjust total balance with battery value and penalties for unmet SOC
|
2025-10-28 02:50:31 +01:00
|
|
|
|
if self.simulation.battery:
|
2026-02-27 23:12:08 +01:00
|
|
|
|
battery_energy_content = self.simulation.battery.current_energy_content()
|
|
|
|
|
|
# Apply DC→AC inverter efficiency to residual battery value
|
|
|
|
|
|
# (stored DC energy must pass through inverter to be usable as AC)
|
|
|
|
|
|
if self.simulation.inverter:
|
|
|
|
|
|
battery_energy_content *= self.simulation.inverter.dc_to_ac_efficiency
|
|
|
|
|
|
restwert_akku = battery_energy_content * parameters.ems.preis_euro_pro_wh_akku
|
2025-10-28 02:50:31 +01:00
|
|
|
|
gesamtbilanz += -restwert_akku
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
2026-02-27 23:12:08 +01:00
|
|
|
|
# --- AC charging break-even penalty ---
|
|
|
|
|
|
# Penalise AC charging decisions that cannot be economically justified given the
|
|
|
|
|
|
# round-trip losses (AC→DC charge conversion, battery internal, DC→AC discharge
|
|
|
|
|
|
# conversion) and the best available future electricity prices.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Key insight: energy already stored in the battery (from PV, zero grid cost) covers
|
|
|
|
|
|
# the most expensive future hours first. AC charging from the grid only makes sense
|
|
|
|
|
|
# for the hours that remain uncovered, and only when the discharge price exceeds
|
|
|
|
|
|
# P_charge / η_round_trip.
|
|
|
|
|
|
#
|
|
|
|
|
|
# This penalty does not double-count the simulation result – it amplifies the "bad
|
|
|
|
|
|
# decision" signal so that the genetic algorithm converges faster away from
|
|
|
|
|
|
# unprofitable charging regions.
|
|
|
|
|
|
if (
|
|
|
|
|
|
self.simulation.battery
|
|
|
|
|
|
and self.simulation.inverter
|
|
|
|
|
|
and self.simulation.ac_charge_hours is not None
|
|
|
|
|
|
and self.simulation.elect_price_hourly is not None
|
|
|
|
|
|
and self.simulation.load_energy_array is not None
|
|
|
|
|
|
):
|
|
|
|
|
|
inv = self.simulation.inverter
|
|
|
|
|
|
bat = self.simulation.battery
|
|
|
|
|
|
|
|
|
|
|
|
# Full round-trip efficiency: 1 Wh drawn from grid → η Wh delivered to AC load
|
|
|
|
|
|
round_trip_eff = (
|
|
|
|
|
|
inv.ac_to_dc_efficiency
|
|
|
|
|
|
* bat.charging_efficiency
|
|
|
|
|
|
* bat.discharging_efficiency
|
|
|
|
|
|
* inv.dc_to_ac_efficiency
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 17:54:15 +02:00
|
|
|
|
# Configurable penalty multiplier (default 1 = economic loss in €)
|
|
|
|
|
|
try:
|
|
|
|
|
|
ac_penalty_factor = float(
|
|
|
|
|
|
self.config.optimization.genetic.penalties["ac_charge_break_even"]
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
ac_penalty_factor = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
# A factor of 0 multiplies every penalty term to zero - skip the
|
|
|
|
|
|
# whole computation in that case.
|
|
|
|
|
|
if round_trip_eff > 0 and ac_penalty_factor != 0.0:
|
2026-02-27 23:12:08 +01:00
|
|
|
|
ac_charge_arr = self.simulation.ac_charge_hours
|
|
|
|
|
|
prices_arr = self.simulation.elect_price_hourly
|
|
|
|
|
|
load_arr = self.simulation.load_energy_array
|
|
|
|
|
|
n = len(prices_arr)
|
|
|
|
|
|
|
|
|
|
|
|
# Usable AC energy already in battery from prior PV charging (zero grid cost).
|
|
|
|
|
|
# This covers the most expensive future hours first, pushing AC charging demand
|
|
|
|
|
|
# to cheaper hours where the break-even hurdle may not be met.
|
|
|
|
|
|
initial_soc_wh = (bat.initial_soc_percentage / 100.0) * bat.capacity_wh
|
|
|
|
|
|
free_ac_wh = (
|
|
|
|
|
|
max(0.0, initial_soc_wh - bat.min_soc_wh)
|
|
|
|
|
|
* bat.discharging_efficiency
|
|
|
|
|
|
* inv.dc_to_ac_efficiency
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 17:54:15 +02:00
|
|
|
|
# Prices/loads/free energy are constant within one optimization
|
|
|
|
|
|
# run - compute the break-even lookup once, reuse it for every
|
|
|
|
|
|
# individual (cache is reset per run in optimierung_ems()).
|
|
|
|
|
|
best_prices = getattr(self, "_ac_break_even_best_prices", None)
|
|
|
|
|
|
if best_prices is None:
|
|
|
|
|
|
best_prices = self._ac_break_even_prices(prices_arr, load_arr, free_ac_wh)
|
|
|
|
|
|
self._ac_break_even_best_prices = best_prices
|
2026-02-27 23:12:08 +01:00
|
|
|
|
|
|
|
|
|
|
for hour in range(start_hour, min(len(ac_charge_arr), n)):
|
|
|
|
|
|
ac_factor = ac_charge_arr[hour]
|
|
|
|
|
|
if ac_factor <= 0.0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
charge_price = prices_arr[hour]
|
|
|
|
|
|
if charge_price <= 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-07-15 08:52:16 +02:00
|
|
|
|
# Price that a future AC discharge hour must reach to break
|
|
|
|
|
|
# even. LCOS is defined per DC Wh delivered by the battery;
|
|
|
|
|
|
# dividing it by DC-to-AC efficiency converts it to the
|
|
|
|
|
|
# corresponding cost per useful/exported AC Wh.
|
|
|
|
|
|
lcos_per_wh_dc = getattr(bat, "levelized_cost_of_storage_kwh", 0.0) / 1000.0
|
|
|
|
|
|
break_even_price = (
|
|
|
|
|
|
charge_price / round_trip_eff + lcos_per_wh_dc / inv.dc_to_ac_efficiency
|
|
|
|
|
|
)
|
2026-02-27 23:12:08 +01:00
|
|
|
|
|
2026-07-14 17:54:15 +02:00
|
|
|
|
best_uncovered_price = best_prices[hour]
|
2026-02-27 23:12:08 +01:00
|
|
|
|
|
|
|
|
|
|
if best_uncovered_price < break_even_price:
|
|
|
|
|
|
# AC charging at this hour is economically unjustified.
|
2026-07-14 17:00:07 +02:00
|
|
|
|
# Penalty = excess cost per Wh × DC energy requested this slot.
|
|
|
|
|
|
# max_charge_power_w is a power [W]; the energy movable in
|
|
|
|
|
|
# one slot is power × slot_duration_h (¼ at 15 min).
|
|
|
|
|
|
dc_wh = bat.max_charge_power_w * self.slot_duration_h * ac_factor
|
2026-02-27 23:12:08 +01:00
|
|
|
|
ac_wh = dc_wh / max(inv.ac_to_dc_efficiency, 1e-9)
|
|
|
|
|
|
excess_cost_per_wh = break_even_price - best_uncovered_price
|
|
|
|
|
|
gesamtbilanz += ac_wh * excess_cost_per_wh * ac_penalty_factor
|
|
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
if self.optimize_ev and parameters.eauto and self.simulation.ev:
|
2025-10-28 02:50:31 +01:00
|
|
|
|
try:
|
|
|
|
|
|
penalty = self.config.optimization.genetic.penalties["ev_soc_miss"]
|
|
|
|
|
|
except:
|
|
|
|
|
|
# Use default
|
|
|
|
|
|
penalty = 10
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"Penalty function parameter `ev_soc_miss` not configured, using {}.", penalty
|
|
|
|
|
|
)
|
2025-11-08 15:42:18 +01:00
|
|
|
|
ev_soc_percentage = self.simulation.ev.current_soc_percentage()
|
2026-07-05 12:56:08 +02:00
|
|
|
|
if ev_soc_percentage < parameters.eauto.min_soc_percentage:
|
2025-11-08 15:42:18 +01:00
|
|
|
|
gesamtbilanz += (
|
|
|
|
|
|
abs(parameters.eauto.min_soc_percentage - ev_soc_percentage) * penalty
|
2024-11-26 22:28:05 +01:00
|
|
|
|
)
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2024-09-30 08:23:38 +02:00
|
|
|
|
return (gesamtbilanz,)
|
2024-04-01 14:11:38 +02:00
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
def optimize(
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self,
|
|
|
|
|
|
start_solution: Optional[list[float]] = None,
|
|
|
|
|
|
ngen: int = 200,
|
2024-12-19 14:45:20 +01:00
|
|
|
|
) -> tuple[Any, dict[str, list[Any]]]:
|
2025-10-28 02:50:31 +01:00
|
|
|
|
"""Run the optimization process using a genetic algorithm.
|
|
|
|
|
|
|
|
|
|
|
|
@TODO: optimize() ngen default (200) is different from optimierung_ems() ngen default (400).
|
|
|
|
|
|
"""
|
2026-07-14 17:00:07 +02:00
|
|
|
|
# Re-seed at the actual optimization boundary. Setup and validation may
|
|
|
|
|
|
# consume random values elsewhere in a long-running process; a fixed seed
|
|
|
|
|
|
# must nevertheless produce the same population and result.
|
|
|
|
|
|
if self.fix_seed is not None:
|
|
|
|
|
|
random.seed(self.fix_seed)
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
# Set the number of inviduals in a generation
|
|
|
|
|
|
try:
|
|
|
|
|
|
individuals = self.config.optimization.genetic.individuals
|
|
|
|
|
|
if individuals is None:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except:
|
|
|
|
|
|
individuals = 300
|
|
|
|
|
|
logger.error("Individuals not configured. Using {}.", individuals)
|
|
|
|
|
|
|
|
|
|
|
|
population = self.toolbox.population(n=individuals)
|
2024-04-01 14:11:38 +02:00
|
|
|
|
hof = tools.HallOfFame(1)
|
|
|
|
|
|
stats = tools.Statistics(lambda ind: ind.fitness.values)
|
|
|
|
|
|
stats.register("min", np.min)
|
2025-01-12 14:33:02 +01:00
|
|
|
|
stats.register("avg", np.mean)
|
|
|
|
|
|
stats.register("max", np.max)
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
logger.debug("Start optimize: {}", start_solution)
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2026-07-05 12:56:08 +02:00
|
|
|
|
# Insert the start solution into the population if provided and compatible with the
|
2026-07-14 17:00:07 +02:00
|
|
|
|
# currently active genome layout. EV optimization adds one gene per prediction slot,
|
2026-07-05 12:56:08 +02:00
|
|
|
|
# so a cached solution from a previous run without EV optimization must not be reused.
|
2024-11-26 22:28:05 +01:00
|
|
|
|
if start_solution is not None:
|
2026-07-15 14:19:46 +02:00
|
|
|
|
n_appliance_genes = self.appliance_layout.n_genes
|
|
|
|
|
|
expected_length = (
|
|
|
|
|
|
self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes
|
2026-07-14 17:00:07 +02:00
|
|
|
|
)
|
2026-07-15 14:19:46 +02:00
|
|
|
|
start_solution = self._start_solution_for_slot_grid(start_solution)
|
2026-07-05 12:56:08 +02:00
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
if len(start_solution) != expected_length:
|
2026-07-05 12:56:08 +02:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Ignoring start_solution with incompatible length {} (expected {}).",
|
|
|
|
|
|
len(start_solution),
|
|
|
|
|
|
expected_length,
|
|
|
|
|
|
)
|
2026-07-15 14:19:46 +02:00
|
|
|
|
elif not self._start_solution_matches_layout(start_solution):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Ignoring start_solution: appliance genes do not match the current "
|
|
|
|
|
|
"appliance layout."
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-16 09:56:50 +02:00
|
|
|
|
for _ in range(self.WARM_START_COPIES):
|
2026-07-15 14:19:46 +02:00
|
|
|
|
population.insert(0, creator.Individual(start_solution))
|
2026-07-16 09:56:50 +02:00
|
|
|
|
warm_neighbors = self._mutated_warm_start_neighbors(
|
|
|
|
|
|
start_solution,
|
|
|
|
|
|
self.WARM_START_MUTATIONS,
|
|
|
|
|
|
)
|
|
|
|
|
|
population.extend(creator.Individual(neighbor) for neighbor in warm_neighbors)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Seeded population with {} exact and {} mutated warm-start solutions.",
|
|
|
|
|
|
self.WARM_START_COPIES,
|
|
|
|
|
|
len(warm_neighbors),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
educated_guesses = self._educated_guess_individuals()
|
|
|
|
|
|
population.extend(creator.Individual(guess) for guess in educated_guesses)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Seeded population with {} educated-guess solutions.",
|
|
|
|
|
|
len(educated_guesses),
|
|
|
|
|
|
)
|
2024-09-15 11:08:00 +02:00
|
|
|
|
|
2024-10-22 10:29:57 +02:00
|
|
|
|
# Run the evolutionary algorithm
|
2025-01-12 14:33:02 +01:00
|
|
|
|
pop, log = algorithms.eaMuPlusLambda(
|
2024-10-03 11:05:44 +02:00
|
|
|
|
population,
|
|
|
|
|
|
self.toolbox,
|
|
|
|
|
|
mu=100,
|
2024-10-14 10:10:12 +02:00
|
|
|
|
lambda_=150,
|
2024-10-20 18:18:06 +02:00
|
|
|
|
cxpb=0.6,
|
|
|
|
|
|
mutpb=0.4,
|
2024-10-07 19:52:48 +02:00
|
|
|
|
ngen=ngen,
|
2024-10-03 11:05:44 +02:00
|
|
|
|
stats=stats,
|
|
|
|
|
|
halloffame=hof,
|
2024-10-04 03:11:24 +02:00
|
|
|
|
verbose=self.verbose,
|
2024-10-03 11:05:44 +02:00
|
|
|
|
)
|
2024-09-15 11:08:00 +02:00
|
|
|
|
|
2025-01-12 14:33:02 +01:00
|
|
|
|
# Store fitness history
|
|
|
|
|
|
self.fitness_history = {
|
|
|
|
|
|
"gen": log.select("gen"), # Generation numbers (X-axis)
|
|
|
|
|
|
"avg": log.select("avg"), # Average fitness for each generation (Y-axis)
|
|
|
|
|
|
"max": log.select("max"), # Maximum fitness for each generation (Y-axis)
|
|
|
|
|
|
"min": log.select("min"), # Minimum fitness for each generation (Y-axis)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-26 22:28:05 +01:00
|
|
|
|
member: dict[str, list[float]] = {"bilanz": [], "verluste": [], "nebenbedingung": []}
|
2024-04-17 15:34:03 +02:00
|
|
|
|
for ind in population:
|
2024-10-03 11:05:44 +02:00
|
|
|
|
if hasattr(ind, "extra_data"):
|
|
|
|
|
|
extra_value1, extra_value2, extra_value3 = ind.extra_data
|
|
|
|
|
|
member["bilanz"].append(extra_value1)
|
|
|
|
|
|
member["verluste"].append(extra_value2)
|
|
|
|
|
|
member["nebenbedingung"].append(extra_value3)
|
|
|
|
|
|
|
2024-04-17 15:34:03 +02:00
|
|
|
|
return hof[0], member
|
2024-09-30 08:23:38 +02:00
|
|
|
|
|
2024-10-03 11:05:44 +02:00
|
|
|
|
def optimierung_ems(
|
2024-10-04 03:11:24 +02:00
|
|
|
|
self,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
parameters: GeneticOptimizationParameters,
|
2024-12-15 14:40:03 +01:00
|
|
|
|
start_hour: Optional[int] = None,
|
2024-10-04 03:11:24 +02:00
|
|
|
|
worst_case: bool = False,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
ngen: Optional[int] = None,
|
|
|
|
|
|
) -> GeneticSolution:
|
2024-11-10 23:27:52 +01:00
|
|
|
|
"""Perform EMS (Energy Management System) optimization and visualize results."""
|
2026-07-12 09:01:11 +02:00
|
|
|
|
direct_marketing_enabled = self._direct_marketing_enabled()
|
|
|
|
|
|
parameters = self._parameters_for_config(parameters)
|
2026-07-14 17:00:07 +02:00
|
|
|
|
parameters = self._parameters_for_slot_grid(parameters)
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Home-appliance scheduling now supports sub-hourly intervals via the
|
|
|
|
|
|
# energy-preserving per-slot run profile.
|
|
|
|
|
|
home_appliance_params = parameters.resolved_home_appliances()
|
2026-07-12 09:01:11 +02:00
|
|
|
|
self.optimize_dc_charge = direct_marketing_enabled
|
|
|
|
|
|
self.optimize_battery_grid_export = direct_marketing_enabled
|
|
|
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
|
if start_hour is None:
|
|
|
|
|
|
start_hour = self.ems.start_datetime.hour
|
2025-10-28 02:50:31 +01:00
|
|
|
|
# Start hour has to be in sync with energy management
|
|
|
|
|
|
if start_hour != self.ems.start_datetime.hour:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Start hour not synced. EMS {self.ems.start_datetime.hour} vs. GENETIC {start_hour}."
|
|
|
|
|
|
)
|
2026-06-28 15:45:26 +00:00
|
|
|
|
# start_hour stays the hour-of-day for the appliance-start gene bounds
|
|
|
|
|
|
# (0..23). Everything that indexes the slot arrays (the simulate offset
|
|
|
|
|
|
# and evaluate's break-even loop) uses the slot index instead.
|
|
|
|
|
|
start_slot = self._start_day_slot()
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
# Set the number of generations
|
|
|
|
|
|
generations = ngen
|
|
|
|
|
|
if generations is None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
generations = self.config.optimization.genetic.generations
|
|
|
|
|
|
except:
|
|
|
|
|
|
generations = 400
|
|
|
|
|
|
logger.error("Generations not configured. Using {}.", generations)
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
self.simulation.reset()
|
2026-07-14 17:54:15 +02:00
|
|
|
|
# Prices/loads/initial SoC may differ from the previous run - the
|
|
|
|
|
|
# break-even lookup must be rebuilt lazily on first evaluation.
|
|
|
|
|
|
self._ac_break_even_best_prices = None
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
2026-06-28 15:45:26 +00:00
|
|
|
|
# Initialize PV and EV batteries. slot_duration_h lets the Battery scale
|
|
|
|
|
|
# its power caps (max_charge_power_w) to a per-slot energy cap.
|
2025-01-13 21:44:17 +01:00
|
|
|
|
akku: Optional[Battery] = None
|
|
|
|
|
|
if parameters.pv_akku:
|
2025-10-28 02:50:31 +01:00
|
|
|
|
akku = Battery(
|
|
|
|
|
|
parameters.pv_akku,
|
2026-06-28 15:45:26 +00:00
|
|
|
|
prediction_hours=self.total_slots,
|
|
|
|
|
|
slot_duration_h=self.slot_duration_h,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
)
|
2026-06-28 15:45:26 +00:00
|
|
|
|
akku.set_charge_per_hour(np.full(self.total_slots, 0))
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2024-12-19 14:50:19 +01:00
|
|
|
|
eauto: Optional[Battery] = None
|
2024-11-26 22:28:05 +01:00
|
|
|
|
if parameters.eauto:
|
2024-12-19 14:50:19 +01:00
|
|
|
|
eauto = Battery(
|
2024-11-26 22:28:05 +01:00
|
|
|
|
parameters.eauto,
|
2026-06-28 15:45:26 +00:00
|
|
|
|
prediction_hours=self.total_slots,
|
|
|
|
|
|
slot_duration_h=self.slot_duration_h,
|
2024-11-26 22:28:05 +01:00
|
|
|
|
)
|
2026-06-28 15:45:26 +00:00
|
|
|
|
eauto.set_charge_per_hour(np.full(self.total_slots, 1))
|
2024-11-26 22:28:05 +01:00
|
|
|
|
self.optimize_ev = (
|
2026-07-05 12:56:08 +02:00
|
|
|
|
parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage
|
2024-11-26 22:28:05 +01:00
|
|
|
|
)
|
2025-10-30 17:11:30 +01:00
|
|
|
|
# electrical vehicle charge rates
|
|
|
|
|
|
if parameters.eauto.charge_rates is not None:
|
|
|
|
|
|
self.ev_possible_charge_values = parameters.eauto.charge_rates
|
|
|
|
|
|
elif (
|
|
|
|
|
|
self.config.devices.electric_vehicles
|
|
|
|
|
|
and self.config.devices.electric_vehicles[0]
|
|
|
|
|
|
and self.config.devices.electric_vehicles[0].charge_rates is not None
|
|
|
|
|
|
):
|
|
|
|
|
|
self.ev_possible_charge_values = self.config.devices.electric_vehicles[
|
|
|
|
|
|
0
|
|
|
|
|
|
].charge_rates
|
|
|
|
|
|
else:
|
|
|
|
|
|
warning_msg = "No charge rates provided for electric vehicle - using default."
|
|
|
|
|
|
logger.warning(warning_msg)
|
|
|
|
|
|
self.ev_possible_charge_values = [
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
0.1,
|
|
|
|
|
|
0.2,
|
|
|
|
|
|
0.3,
|
|
|
|
|
|
0.4,
|
|
|
|
|
|
0.5,
|
|
|
|
|
|
0.6,
|
|
|
|
|
|
0.7,
|
|
|
|
|
|
0.8,
|
|
|
|
|
|
0.9,
|
|
|
|
|
|
1.0,
|
|
|
|
|
|
]
|
2024-11-26 22:28:05 +01:00
|
|
|
|
else:
|
2024-10-14 10:10:12 +02:00
|
|
|
|
self.optimize_ev = False
|
|
|
|
|
|
|
2026-03-17 12:41:15 +01:00
|
|
|
|
# Battery AC charge rates — use the battery's configured charge_rates so the
|
|
|
|
|
|
# optimizer can select partial AC charge power (e.g. 10 %, 50 %, 100 %) instead
|
|
|
|
|
|
# of always forcing full power. Falls back to [1.0] when not configured.
|
|
|
|
|
|
if parameters.pv_akku and parameters.pv_akku.charge_rates:
|
|
|
|
|
|
self.bat_possible_charge_values = [
|
|
|
|
|
|
r for r in parameters.pv_akku.charge_rates if r > 0.0
|
|
|
|
|
|
] or [1.0]
|
|
|
|
|
|
elif (
|
|
|
|
|
|
self.config.devices.batteries
|
|
|
|
|
|
and self.config.devices.batteries[0]
|
|
|
|
|
|
and self.config.devices.batteries[0].charge_rates
|
|
|
|
|
|
):
|
|
|
|
|
|
self.bat_possible_charge_values = [
|
|
|
|
|
|
r for r in self.config.devices.batteries[0].charge_rates if r > 0.0
|
|
|
|
|
|
] or [1.0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.bat_possible_charge_values = [1.0]
|
|
|
|
|
|
logger.debug("Battery AC charge levels: {}", self.bat_possible_charge_values)
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Initialize the flexible consumers (home appliances) and their genome
|
|
|
|
|
|
# layout. slot0_datetime (midnight of the start day) turns decoded start
|
|
|
|
|
|
# slots into absolute local timestamps and drives DAILY day grouping.
|
|
|
|
|
|
self._slot0_datetime = self.ems.start_datetime.set(
|
|
|
|
|
|
hour=0, minute=0, second=0, microsecond=0
|
|
|
|
|
|
)
|
|
|
|
|
|
home_appliances = [
|
2024-11-26 00:53:16 +01:00
|
|
|
|
HomeAppliance(
|
2026-07-15 14:19:46 +02:00
|
|
|
|
parameters=appliance_params,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
optimization_hours=self.config.optimization.horizon_hours,
|
2026-06-28 15:45:26 +00:00
|
|
|
|
prediction_hours=self.total_slots,
|
|
|
|
|
|
slot_duration_h=self.slot_duration_h,
|
2024-10-06 14:29:23 +02:00
|
|
|
|
)
|
2026-07-15 14:19:46 +02:00
|
|
|
|
for appliance_params in home_appliance_params
|
|
|
|
|
|
]
|
|
|
|
|
|
self.appliance_layout = self._build_appliance_layout(
|
|
|
|
|
|
home_appliances, self._slot0_datetime
|
2024-10-04 03:11:24 +02:00
|
|
|
|
)
|
2024-09-30 08:23:38 +02:00
|
|
|
|
|
2026-06-28 15:45:26 +00:00
|
|
|
|
# Initialize the inverter and energy management system. slot_duration_h
|
|
|
|
|
|
# lets the Inverter scale max_power_wh to a per-slot energy cap.
|
2025-01-13 21:44:17 +01:00
|
|
|
|
inverter: Optional[Inverter] = None
|
|
|
|
|
|
if parameters.inverter:
|
|
|
|
|
|
inverter = Inverter(
|
|
|
|
|
|
parameters.inverter,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
battery=akku,
|
2026-06-28 15:45:26 +00:00
|
|
|
|
slot_duration_h=self.slot_duration_h,
|
2025-01-13 21:44:17 +01:00
|
|
|
|
)
|
2025-01-12 05:19:37 +01:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
# Prepare device simulation
|
|
|
|
|
|
self.simulation.prepare(
|
|
|
|
|
|
parameters=parameters.ems,
|
|
|
|
|
|
optimization_hours=self.config.optimization.horizon_hours,
|
2026-06-28 15:45:26 +00:00
|
|
|
|
prediction_hours=self.total_slots,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
inverter=inverter, # battery is part of inverter
|
2024-12-22 12:03:48 +01:00
|
|
|
|
ev=eauto,
|
2026-07-15 14:19:46 +02:00
|
|
|
|
home_appliances=home_appliances,
|
2026-07-12 09:01:11 +02:00
|
|
|
|
direct_marketing_enabled=direct_marketing_enabled,
|
2024-10-03 11:05:44 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-15 14:19:46 +02:00
|
|
|
|
# Setup the DEAP environment and optimization process. The appliance
|
|
|
|
|
|
# genome layout (built above) drives the appliance gene block; evaluate
|
|
|
|
|
|
# gets the slot index (its break-even loop walks the slot arrays from "now").
|
|
|
|
|
|
self.setup_deap_environment(
|
|
|
|
|
|
{"home_appliance": self.appliance_layout.n_genes}, start_hour
|
|
|
|
|
|
)
|
2024-10-04 03:11:24 +02:00
|
|
|
|
self.toolbox.register(
|
|
|
|
|
|
"evaluate",
|
2026-06-28 15:45:26 +00:00
|
|
|
|
lambda ind: self.evaluate(ind, parameters, start_slot, worst_case),
|
2024-10-04 03:11:24 +02:00
|
|
|
|
)
|
2024-12-19 14:45:20 +01:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
start_time = time.time()
|
|
|
|
|
|
start_solution, extra_data = self.optimize(parameters.start_solution, ngen=generations)
|
|
|
|
|
|
elapsed_time = time.time() - start_time
|
|
|
|
|
|
logger.debug(f"Time evaluate inner: {elapsed_time:.4f} sec.")
|
2024-10-03 11:05:44 +02:00
|
|
|
|
|
2024-10-04 03:11:24 +02:00
|
|
|
|
# Perform final evaluation on the best solution
|
2025-10-28 02:50:31 +01:00
|
|
|
|
simulation_result = self.evaluate_inner(start_solution)
|
|
|
|
|
|
|
|
|
|
|
|
# Prepare results
|
2026-07-15 14:19:46 +02:00
|
|
|
|
discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = (
|
|
|
|
|
|
self.split_individual(start_solution)
|
2024-10-03 11:05:44 +02:00
|
|
|
|
)
|
2026-07-15 14:19:46 +02:00
|
|
|
|
|
|
|
|
|
|
# Materialize the per-device appliance results only for the final best
|
|
|
|
|
|
# solution. Each appliance's load curve (already built by the final
|
|
|
|
|
|
# evaluate_inner above) starts at slot 0; slice it to the simulation
|
|
|
|
|
|
# window so it aligns with the other per-slot result arrays.
|
|
|
|
|
|
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
|
|
|
|
|
|
home_appliance_energy_wh: dict[str, list[float]] = {}
|
|
|
|
|
|
appliance_starts: dict[str, list[Any]] = {}
|
|
|
|
|
|
timezone = self.config.general.timezone
|
|
|
|
|
|
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
|
|
|
|
|
|
device_id = appliance.device_id
|
|
|
|
|
|
home_appliance_energy_wh[device_id] = appliance.get_load_curve()[start_slot:].tolist()
|
|
|
|
|
|
starts = sorted(starts_per_appliance.get(appliance_index, []))
|
|
|
|
|
|
appliance_starts[device_id] = [
|
|
|
|
|
|
self._slot0_datetime.add(
|
|
|
|
|
|
seconds=start * appliance.slot_interval_seconds
|
|
|
|
|
|
).in_timezone(timezone)
|
|
|
|
|
|
for start in starts
|
|
|
|
|
|
]
|
|
|
|
|
|
simulation_result["home_appliance_energy_wh"] = home_appliance_energy_wh
|
|
|
|
|
|
|
|
|
|
|
|
# Deprecated single-device hourly start (kept for backward compatibility).
|
|
|
|
|
|
# Only meaningful for the legacy case: exactly one appliance on the hourly
|
|
|
|
|
|
# grid. Otherwise None; use appliance_starts instead.
|
|
|
|
|
|
washingstart_int: Optional[int] = None
|
|
|
|
|
|
if self.slots_per_hour == 1 and len(self.simulation.home_appliances) == 1:
|
|
|
|
|
|
single_starts = starts_per_appliance.get(0, [])
|
|
|
|
|
|
if single_starts:
|
|
|
|
|
|
washingstart_int = int(min(single_starts))
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
2026-07-05 12:56:08 +02:00
|
|
|
|
eautocharge_hours_float = None
|
|
|
|
|
|
if eautocharge_hours_index is not None and self.simulation.ev is not None:
|
|
|
|
|
|
eautocharge_hours_float = self.simulation.ev.charge_array.tolist()
|
2024-10-16 15:40:04 +02:00
|
|
|
|
|
2025-11-08 15:42:18 +01:00
|
|
|
|
# Simulation may have changed something, use simulation values
|
|
|
|
|
|
ac_charge_hours = self.simulation.ac_charge_hours
|
|
|
|
|
|
if ac_charge_hours is None:
|
|
|
|
|
|
ac_charge_hours = []
|
|
|
|
|
|
else:
|
|
|
|
|
|
ac_charge_hours = ac_charge_hours.tolist()
|
|
|
|
|
|
dc_charge_hours = self.simulation.dc_charge_hours
|
|
|
|
|
|
if dc_charge_hours is None:
|
|
|
|
|
|
dc_charge_hours = []
|
|
|
|
|
|
else:
|
|
|
|
|
|
dc_charge_hours = dc_charge_hours.tolist()
|
|
|
|
|
|
discharge = self.simulation.bat_discharge_hours
|
|
|
|
|
|
if discharge is None:
|
|
|
|
|
|
discharge = []
|
|
|
|
|
|
else:
|
|
|
|
|
|
discharge = discharge.tolist()
|
2026-07-12 09:01:11 +02:00
|
|
|
|
battery_grid_export = self.simulation.bat_grid_export_hours
|
|
|
|
|
|
if not direct_marketing_enabled or battery_grid_export is None:
|
|
|
|
|
|
battery_grid_export = []
|
|
|
|
|
|
else:
|
|
|
|
|
|
battery_grid_export = battery_grid_export.tolist()
|
2025-11-08 15:42:18 +01:00
|
|
|
|
|
2026-07-14 17:00:07 +02:00
|
|
|
|
# Visualize the results in PDF. Skippable via config — matplotlib PDF
|
|
|
|
|
|
# generation costs several seconds per run, which headless setups
|
|
|
|
|
|
# (API/Node-RED polling) never look at.
|
|
|
|
|
|
if getattr(self.config.optimization, "visualize_pdf", True):
|
|
|
|
|
|
try:
|
|
|
|
|
|
from akkudoktoreos.utils.visualize import prepare_visualize
|
2024-12-24 13:10:31 +01:00
|
|
|
|
|
2026-07-14 17:00:07 +02:00
|
|
|
|
visualize = {
|
|
|
|
|
|
"ac_charge": ac_charge_hours,
|
|
|
|
|
|
"dc_charge": dc_charge_hours,
|
|
|
|
|
|
"discharge_allowed": discharge,
|
|
|
|
|
|
"battery_grid_export_allowed": battery_grid_export,
|
|
|
|
|
|
"eautocharge_hours_float": eautocharge_hours_float,
|
|
|
|
|
|
"result": simulation_result,
|
|
|
|
|
|
"eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None,
|
|
|
|
|
|
"start_solution": start_solution,
|
|
|
|
|
|
"spuelstart": washingstart_int,
|
|
|
|
|
|
"extra_data": extra_data,
|
|
|
|
|
|
"fitness_history": self.fitness_history,
|
|
|
|
|
|
"fixed_seed": self.fix_seed,
|
|
|
|
|
|
}
|
2026-03-01 11:52:08 +01:00
|
|
|
|
|
2026-07-14 17:00:07 +02:00
|
|
|
|
prepare_visualize(parameters, visualize, start_hour=start_slot)
|
2026-03-01 11:52:08 +01:00
|
|
|
|
|
2026-07-14 17:00:07 +02:00
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
error_msg = f"Visualization failed: {ex}"
|
|
|
|
|
|
logger.error(error_msg)
|
2024-10-04 03:11:24 +02:00
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
return GeneticSolution(
|
2024-11-26 22:28:05 +01:00
|
|
|
|
**{
|
2025-11-08 15:42:18 +01:00
|
|
|
|
"ac_charge": ac_charge_hours,
|
|
|
|
|
|
"dc_charge": dc_charge_hours,
|
2024-11-26 22:28:05 +01:00
|
|
|
|
"discharge_allowed": discharge,
|
2026-07-12 09:01:11 +02:00
|
|
|
|
"battery_grid_export_allowed": battery_grid_export,
|
2024-11-26 22:28:05 +01:00
|
|
|
|
"eautocharge_hours_float": eautocharge_hours_float,
|
2025-10-28 02:50:31 +01:00
|
|
|
|
"result": GeneticSimulationResult(**simulation_result),
|
|
|
|
|
|
"eauto_obj": self.simulation.ev,
|
2024-11-26 22:28:05 +01:00
|
|
|
|
"start_solution": start_solution,
|
|
|
|
|
|
"washingstart": washingstart_int,
|
2026-07-15 14:19:46 +02:00
|
|
|
|
"appliance_starts": appliance_starts,
|
2024-11-26 22:28:05 +01:00
|
|
|
|
}
|
|
|
|
|
|
)
|