Files
EOS/src/akkudoktoreos/optimization/genetic/genetic.py
T

1440 lines
62 KiB
Python
Raw Normal View History

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
from typing import Any, Optional
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
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
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,
)
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-10-07 19:59:31 +02:00
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(
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(
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(
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,
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,
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,
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,
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
)
direct_marketing_enabled: bool = Field(
default=False,
json_schema_extra={
"description": "Use direct marketing behavior for feed-in/export decisions."
},
)
battery: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
ev: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
home_appliance: Optional[HomeAppliance] = Field(
default=None, json_schema_extra={"description": "TBD."}
)
inverter: Optional[Inverter] = Field(default=None, json_schema_extra={"description": "TBD."})
2024-11-26 22:28:05 +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(
default=None, json_schema_extra={"description": "TBD"}
)
bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
json_schema_extra={
"description": "Hourly permission for battery discharge into the grid."
},
)
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(
default=None, json_schema_extra={"description": "TBD"}
2025-11-08 15:42:18 +01:00
)
home_appliance_start_hour: Optional[int] = Field(
default=None,
json_schema_extra={"description": "Home appliance start hour - None denotes no start."},
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,
home_appliance: Optional[HomeAppliance] = None,
inverter: Optional[Inverter] = None,
direct_marketing_enabled: bool = False,
2025-10-28 02:50:31 +01:00
) -> None:
"""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
self.direct_marketing_enabled = direct_marketing_enabled
# 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 = (
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
)
)
# Associate devices
2025-10-28 02:50:31 +01:00
if inverter:
self.battery = inverter.battery
else:
self.battery = None
self.ev = ev
self.home_appliance = home_appliance
self.inverter = inverter
# 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)
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)
self.home_appliance_start_hour = None
2025-10-28 02:50:31 +01:00
def reset(self) -> None:
if self.ev:
self.ev.reset()
if self.battery:
self.battery.reset()
2025-11-08 15:42:18 +01:00
self.home_appliance_start_hour = None
2025-10-28 02:50:31 +01:00
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
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
home_appliance_fast = self.home_appliance
inverter_fast = self.inverter
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
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")
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)
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:
# 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()
# 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
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
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,
)
else:
# Default return if no battery is available
soc_per_hour = np.full((total_hours), 0)
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:
# 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
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
if home_appliance_fast and self.home_appliance_start_hour is not None:
2025-11-08 15:42:18 +01:00
home_appliance_enabled = True
# Pre-allocate arrays for the results, optimized for speed
home_appliance_wh_per_hour = np.full((total_hours), np.nan)
2025-11-08 15:42:18 +01:00
self.home_appliance_start_hour = home_appliance_fast.set_starting_time(
self.home_appliance_start_hour, start_hour
)
else:
home_appliance_enabled = False
# 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
# Home appliances
2025-11-08 15:42:18 +01:00
if home_appliance_enabled:
ha_load = home_appliance_fast.get_load_for_hour(hour) # type: ignore[union-attr]
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
# 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]
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,
) = 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]
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
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
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
feed_in_tariff_per_hour[hour_idx] = hourly_feed_in_tariff
2025-10-28 02:50:31 +01:00
# Financial calculations
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
2025-11-08 15:42:18 +01:00
revenue_per_hour[hour_idx] = (
energy_feedin_grid_actual * hourly_feed_in_tariff
2025-11-08 15:42:18 +01:00
)
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,
"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."""
# 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."""
2024-11-26 22:28:05 +01:00
self.opti_param: dict[str, Any] = {}
# 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).
self.fixed_eauto_hours = self.total_slots - (
self.config.optimization.horizon_hours * self.slots_per_hour
2025-10-28 02:50:31 +01:00
)
self.ev_possible_charge_values: list[float] = [1.0]
# 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
self.optimize_ev = True
2024-10-20 18:18:06 +02:00
self.optimize_dc_charge = False
self.optimize_battery_grid_export = False
self.fitness_history: dict[str, Any] = {}
# 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
random.seed(self.fix_seed)
2024-10-04 03:11:24 +02:00
2025-10-28 02:50:31 +01:00
# Create Simulation
self.simulation = GeneticSimulation()
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
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(
update={
"einspeiseverguetung_euro_pro_wh": list(
parameters.ems.strompreis_euro_pro_wh
)
},
deep=True,
)
return parameters.model_copy(update={"ems": ems_parameters}, deep=True)
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
) -> 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)
# Battery AC charge uses its own charge-level list (bat_possible_charge_values).
len_bat = len(self.bat_possible_charge_values)
# 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)
# Grid export: next state, if direct marketing/export optimization is enabled
2024-10-16 15:40:04 +02:00
# Idle states
idle_mask = (discharge_hours_bin_np >= 0) & (discharge_hours_bin_np < len_bat)
# Discharge states
discharge_mask = (discharge_hours_bin_np >= len_bat) & (
discharge_hours_bin_np < 2 * len_bat
)
# AC states
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)
# DC states (if enabled)
2024-10-20 18:18:06 +02:00
if self.optimize_dc_charge:
dc_not_allowed_state = 3 * len_bat
dc_allowed_state = 3 * len_bat + 1
dc_charge = np.where(discharge_hours_bin_np == dc_allowed_state, 1, 0)
2024-10-20 18:18:06 +02:00
else:
dc_charge = np.ones_like(discharge_hours_bin_np, dtype=float)
2024-10-16 15:40:04 +02: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)
ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices]
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)
# Idle is just 0, already default.
2024-10-16 15:40:04 +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]]:
"""Custom mutation function for the individual."""
# Calculate the number of states using battery charge levels
len_bat = len(self.bat_possible_charge_values)
if self.optimize_dc_charge:
total_states = 3 * len_bat + 2
else:
total_states = 3 * len_bat
if self.optimize_battery_grid_export:
total_states += 1
# 1. Mutating the charge_discharge part
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)
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
individual[: self.total_slots] = charge_discharge_mutated
2024-10-16 15:40:04 +02:00
# 2. Mutating the EV charge part, if active
2024-10-14 10:46:14 +02:00
if self.optimize_ev:
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)
ev_charge_part_mutated[self.total_slots - self.fixed_eauto_hours :] = [
2025-01-18 14:26:34 +01:00
0
] * self.fixed_eauto_hours
individual[self.total_slots : self.total_slots * 2] = ev_charge_part_mutated
2024-10-14 10:46:14 +02:00
# 3. Mutating the appliance start time, if applicable
if self.opti_param["home_appliance"] > 0:
2024-10-14 10:46:14 +02:00
appliance_part = [individual[-1]]
2024-10-22 10:29:57 +02:00
(appliance_part_mutated,) = self.toolbox.mutate_hour(appliance_part)
2024-10-14 10:46:14 +02:00
individual[-1] = appliance_part_mutated[0]
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 = [
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 += [
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
# Add the start time of the household appliance if it's being optimized
if self.opti_param["home_appliance"] > 0:
2024-10-14 10:46:14 +02:00
individual_components += [self.toolbox.attr_int()]
return creator.Individual(individual_components)
2024-12-19 14:45:20 +01:00
def merge_individual(
self,
discharge_hours_bin: np.ndarray,
eautocharge_hours_index: Optional[np.ndarray],
washingstart_int: Optional[int],
) -> 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.
washingstart_int (Optional[int]): Dishwasher start time as integer, or None.
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:
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
individual.extend([0] * self.total_slots)
2024-12-19 14:45:20 +01:00
# Add dishwasher start time if applicable
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
individual.append(washingstart_int)
elif self.opti_param.get("home_appliance", 0) > 0:
# Falls ein Haushaltsgerät optimiert wird, aber kein Startzeitpunkt vorhanden ist
individual.append(0)
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]
) -> tuple[np.ndarray, Optional[np.ndarray], Optional[int]]:
"""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),
2024-10-04 03:11:24 +02:00
3. Dishwasher start time (integer if applicable).
"""
2024-12-19 14:45:20 +01:00
# Discharge hours as a NumPy array of ints
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(
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
)
2024-12-19 14:45:20 +01:00
# Washing machine start time as an integer (if applicable)
washingstart_int = (
2024-11-26 22:28:05 +01:00
int(individual[-1])
if self.opti_param and self.opti_param.get("home_appliance", 0) > 0
2024-10-04 03:11:24 +02:00
else None
)
2024-12-19 14:45:20 +01:00
2024-11-26 22:28:05 +01:00
return discharge_hours_bin, eautocharge_hours_index, washingstart_int
2024-08-24 10:22:49 +02:00
2024-11-15 22:27:25 +01:00
def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> None:
"""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
# 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-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()
# 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
# 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
# With battery grid export: + 1 additional state
if self.optimize_dc_charge:
total_states = 3 * len_bat + 2
else:
total_states = 3 * len_bat
if self.optimize_battery_grid_export:
total_states += 1
# State space: 0 .. (total_states - 1)
self.toolbox.register("attr_discharge_state", random.randint, 0, total_states - 1)
# EV attributes (separate index space)
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,
len_ev - 1,
2024-10-22 10:29:57 +02:00
)
# Household appliance start time
2024-05-03 10:56:13 +02:00
self.toolbox.register("attr_int", random.randint, start_hour, 23)
2024-10-03 11:05:44 +02: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)
self.toolbox.register("mate", tools.cxTwoPoint)
# Mutation operator for battery charge/discharge states
self.toolbox.register(
"mutate_charge_discharge", tools.mutUniformInt, low=0, up=total_states - 1, indpb=0.2
)
# 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,
up=len_ev - 1,
2024-10-22 10:29:57 +02:00
indpb=0.2,
)
# Mutation for household appliance
2024-10-22 08:58:07 +02:00
self.toolbox.register("mutate_hour", tools.mutUniformInt, low=start_hour, up=23, indpb=0.2)
# 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]:
"""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()
discharge_hours_bin, eautocharge_hours_index, washingstart_int = 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
2025-10-28 02:50:31 +01:00
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int:
2025-11-08 15:42:18 +01:00
# Set start hour for appliance
self.simulation.home_appliance_start_hour = washingstart_int
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
)
2025-11-08 15:42:18 +01:00
self.simulation.bat_discharge_hours = discharge
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:
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:
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],
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
self.simulation.ev_charge_hours = np.full(self.total_slots, 0)
2024-12-19 14:45:20 +01: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 (023 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.
"""
try:
2025-11-08 15:42:18 +01:00
simulation_result = self.evaluate_inner(individual)
except Exception as e:
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-19 14:45:20 +01:00
# EV 100% & charge not allowed
if self.optimize_ev:
2025-11-08 15:42:18 +01:00
discharge_hours_bin, eautocharge_hours_index, washingstart_int = self.split_individual(
individual
)
eauto_soc_per_hour = np.array(
simulation_result.get("EAuto_SoC_pro_Stunde", [])
) # Beispielkey
2024-10-03 11:05:44 +02:00
2024-12-19 14:45:20 +01:00
if eauto_soc_per_hour is None or eautocharge_hours_index is None:
raise ValueError("eauto_soc_per_hour or eautocharge_hours_index is None")
min_length = min(eauto_soc_per_hour.size, eautocharge_hours_index.size)
eauto_soc_per_hour_tail = eauto_soc_per_hour[-min_length:]
eautocharge_hours_index_tail = eautocharge_hours_index[-min_length:]
# Mask
invalid_charge_mask = (eauto_soc_per_hour_tail == 100) & (
eautocharge_hours_index_tail > 0
)
if np.any(invalid_charge_mask):
invalid_indices = np.where(invalid_charge_mask)[0]
if len(invalid_indices) > 1:
eautocharge_hours_index_tail[invalid_indices] = 0
2024-12-19 14:45:20 +01:00
eautocharge_hours_index[-min_length:] = eautocharge_hours_index_tail.tolist()
adjusted_individual = self.merge_individual(
discharge_hours_bin, eautocharge_hours_index, washingstart_int
)
individual[:] = adjusted_individual
2024-12-19 14:45:20 +01:00
# New check: Activate discharge when battery SoC is 0
# battery_soc_per_hour = np.array(
# o.get("akku_soc_pro_stunde", [])
# ) # Example key for battery SoC
# 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)
# # # 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
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
# # 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
# 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:
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
# --- 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
)
if round_trip_eff > 0:
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
)
# 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
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
# Price that a future discharge hour must reach to break even
break_even_price = charge_price / round_trip_eff
# 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
if best_uncovered_price < break_even_price:
# AC charging at this hour is economically unjustified.
# Penalty = excess cost per Wh × DC energy requested this hour.
dc_wh = bat.max_charge_power_w * ac_factor
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()
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
return (gesamtbilanz,)
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).
"""
# 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)
hof = tools.HallOfFame(1)
stats = tools.Statistics(lambda ind: ind.fitness.values)
stats.register("min", np.min)
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
# Insert the start solution into the population if provided and compatible with the
# currently active genome layout. EV optimization adds one gene per prediction hour,
# 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:
expected_length = self.config.prediction.hours
if self.optimize_ev:
expected_length += self.config.prediction.hours
if self.opti_param.get("home_appliance", 0) > 0:
expected_length += 1
if len(start_solution) == expected_length:
for _ in range(10):
population.insert(0, creator.Individual(start_solution))
else:
logger.warning(
"Ignoring start_solution with incompatible length {} (expected {}).",
len(start_solution),
expected_length,
)
2024-09-15 11:08:00 +02:00
2024-10-22 10:29:57 +02:00
# Run the evolutionary algorithm
pop, log = algorithms.eaMuPlusLambda(
2024-10-03 11:05:44 +02:00
population,
self.toolbox,
mu=100,
lambda_=150,
2024-10-20 18:18:06 +02:00
cxpb=0.6,
mutpb=0.4,
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
# 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": []}
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)
return hof[0], member
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,
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:
"""Perform EMS (Energy Management System) optimization and visualize results."""
direct_marketing_enabled = self._direct_marketing_enabled()
parameters = self._parameters_for_config(parameters)
self.optimize_dc_charge = direct_marketing_enabled
self.optimize_battery_grid_export = direct_marketing_enabled
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}."
)
# 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)
2025-10-28 02:50:31 +01:00
self.simulation.reset()
2024-12-19 14:45:20 +01: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.
akku: Optional[Battery] = None
if parameters.pv_akku:
2025-10-28 02:50:31 +01:00
akku = Battery(
parameters.pv_akku,
prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
2025-10-28 02:50:31 +01: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,
prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
2024-11-26 22:28:05 +01:00
)
eauto.set_charge_per_hour(np.full(self.total_slots, 1))
2024-11-26 22:28:05 +01:00
self.optimize_ev = (
parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage
2024-11-26 22:28:05 +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:
self.optimize_ev = False
# 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)
2024-10-04 03:11:24 +02:00
# Initialize household appliance if applicable
dishwasher = (
HomeAppliance(
parameters=parameters.dishwasher,
2025-10-28 02:50:31 +01:00
optimization_hours=self.config.optimization.horizon_hours,
prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
2024-10-06 14:29:23 +02:00
)
if parameters.dishwasher is not None
2024-10-04 03:11:24 +02:00
else None
)
# Initialize the inverter and energy management system. slot_duration_h
# lets the Inverter scale max_power_wh to a per-slot energy cap.
inverter: Optional[Inverter] = None
if parameters.inverter:
inverter = Inverter(
parameters.inverter,
2025-10-28 02:50:31 +01:00
battery=akku,
slot_duration_h=self.slot_duration_h,
)
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,
prediction_hours=self.total_slots,
2025-10-28 02:50:31 +01:00
inverter=inverter, # battery is part of inverter
ev=eauto,
home_appliance=dishwasher,
direct_marketing_enabled=direct_marketing_enabled,
2024-10-03 11:05:44 +02:00
)
# Setup the DEAP environment and optimization process. setup_deap gets
# the hour-of-day (appliance gene bounds); evaluate gets the slot index
# (its break-even loop walks the slot arrays from "now").
self.setup_deap_environment({"home_appliance": 1 if dishwasher else 0}, start_hour)
2024-10-04 03:11:24 +02:00
self.toolbox.register(
"evaluate",
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
2024-11-26 22:28:05 +01:00
discharge_hours_bin, eautocharge_hours_index, washingstart_int = self.split_individual(
2024-10-10 15:00:32 +02:00
start_solution
2024-10-03 11:05:44 +02:00
)
2025-10-28 02:50:31 +01:00
# home appliance may have choosen a different appliance start hour
if self.simulation.home_appliance:
2025-11-08 15:42:18 +01:00
washingstart_int = self.simulation.home_appliance_start_hour
2025-10-28 02:50:31 +01: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()
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
# Visualize the results in PDF
try:
from akkudoktoreos.utils.visualize import prepare_visualize
2024-12-24 13:10:31 +01: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,
}
prepare_visualize(parameters, visualize, start_hour=start_hour)
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,
"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,
}
)