feat: Direktvermarktung mit Batterie-Netzeinspeisung

Fügt einen Direktvermarktungs-Modus (feedintariff.direct_marketing_enabled)
hinzu, der den Börsenpreis als Einspeisevergütung nutzt und aktive
Batterie-Entladung ins Netz (battery_grid_export_allowed) sowie
DC-Charge-Bypass optimiert.

- FeedInTariffEnergyCharts-Provider (Börsen-Einspeisetarif inkl. Prognose)
- Inverter: DC/AC-Wirkungsgrade und Batterie-Grid-Export in process_energy
- Genetik: Export-/DC-Charge-Zustände, Restwert-Bewertung des Akkus
- Solution-Result: neues Feld Feed_in_tariff (verwendeter Tarif je Stunde)
- Tests für neue Provider, Solution und Simulation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas
2026-07-12 09:01:33 +02:00
co-authored by Claude Fable 5
parent cc583600d8
commit 7f2ac9098c
19 changed files with 960 additions and 72 deletions
+45 -1
View File
@@ -30,8 +30,23 @@ class Inverter:
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency
self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w
def _discharge_battery_to_ac(self, requested_ac_wh: float, hour: int) -> tuple[float, float]:
"""Discharge battery energy and convert it to AC energy."""
if not self.battery or requested_ac_wh <= 0.0:
return 0.0, 0.0
dc_request = requested_ac_wh / self.dc_to_ac_efficiency
battery_discharge_dc, discharge_losses = self.battery.discharge_energy(dc_request, hour)
battery_discharge_ac = battery_discharge_dc * self.dc_to_ac_efficiency
inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac
return battery_discharge_ac, discharge_losses + inverter_discharge_losses
def process_energy(
self, generation: float, consumption: float, hour: int
self,
generation: float,
consumption: float,
hour: int,
allow_battery_grid_export: bool = False,
) -> tuple[float, float, float, float]:
losses = 0.0
grid_export = 0.0
@@ -59,6 +74,7 @@ class Inverter:
# Remaining load Self Consumption not perfect
remaining_load_evq = (generation - consumption) * (1.0 - scr)
from_battery_dc = 0.0
if remaining_load_evq > 0:
# Akku muss den Restverbrauch decken
if self.battery:
@@ -105,6 +121,20 @@ class Inverter:
consumption + from_battery_ac
) # Self-consumption is equal to the load
if allow_battery_grid_export and self.battery:
export_capacity = max(self.max_power_wh - consumption - grid_export, 0.0)
max_discharge_dc = getattr(self.battery, "max_charge_power_w", None)
if max_discharge_dc is not None:
remaining_battery_ac = max(
(max_discharge_dc - from_battery_dc) * dc_to_ac_eff, 0.0
)
export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
grid_export += battery_export_ac
losses += battery_export_losses
else:
# Case 2: Insufficient generation, cover shortfall
shortfall = consumption - generation
@@ -129,4 +159,18 @@ class Inverter:
grid_import = shortfall - battery_discharge_ac
self_consumption = generation + battery_discharge_ac
if allow_battery_grid_export and self.battery and grid_import <= 0.0:
export_capacity = max(self.max_power_wh - consumption, 0.0)
max_discharge_dc = getattr(self.battery, "max_charge_power_w", None)
if max_discharge_dc is not None:
remaining_battery_ac = max(
(max_discharge_dc - battery_discharge_dc) * dc_to_ac_eff, 0.0
)
export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
grid_export += battery_export_ac
losses += battery_export_losses
return grid_export, grid_import, losses, self_consumption
+122 -14
View File
@@ -76,7 +76,12 @@ class GeneticSimulation(PydanticBaseModel):
"description": "An array of floats representing the feed-in compensation in euros per watt-hour."
},
)
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(
@@ -93,6 +98,12 @@ class GeneticSimulation(PydanticBaseModel):
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"}
)
@@ -112,6 +123,7 @@ class GeneticSimulation(PydanticBaseModel):
ev: Optional[Battery] = None,
home_appliance: Optional[HomeAppliance] = None,
inverter: Optional[Inverter] = None,
direct_marketing_enabled: bool = False,
) -> None:
"""Prepare simulation runs.
@@ -119,13 +131,14 @@ class GeneticSimulation(PydanticBaseModel):
"""
self.optimization_hours = optimization_hours
self.prediction_hours = prediction_hours
self.direct_marketing_enabled = direct_marketing_enabled
# Load arrays from provided EMS parameters
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 = (
parameters.einspeiseverguetung_euro_pro_wh
np.array(parameters.einspeiseverguetung_euro_pro_wh, float)
if isinstance(parameters.einspeiseverguetung_euro_pro_wh, list)
else np.full(
len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float
@@ -145,6 +158,7 @@ class GeneticSimulation(PydanticBaseModel):
self.ac_charge_hours = np.full(self.prediction_hours, 0.0)
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)
self.ev_charge_hours = np.full(self.prediction_hours, 0.0)
self.ev_discharge_hours = np.full(self.prediction_hours, 0.0)
self.home_appliance_start_hour = None
@@ -172,6 +186,7 @@ class GeneticSimulation(PydanticBaseModel):
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
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
@@ -179,6 +194,7 @@ class GeneticSimulation(PydanticBaseModel):
ev_fast = self.ev
home_appliance_fast = self.home_appliance
inverter_fast = self.inverter
direct_marketing_enabled_fast = self.direct_marketing_enabled
# Check for simulation integrity (in a way that mypy understands)
if (
@@ -190,6 +206,7 @@ class GeneticSimulation(PydanticBaseModel):
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
or ev_discharge_hours_fast is None
):
missing = []
@@ -209,6 +226,8 @@ class GeneticSimulation(PydanticBaseModel):
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")
if ev_discharge_hours_fast is None:
missing.append("EV Discharge Hours")
msg = ", ".join(missing)
@@ -235,6 +254,7 @@ class GeneticSimulation(PydanticBaseModel):
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)
# Set initial state
if battery_fast:
@@ -272,7 +292,18 @@ class GeneticSimulation(PydanticBaseModel):
# Fill the discharge array of the battery
bat_discharge_hours_fast[0:start_hour] = 0
bat_discharge_hours_fast[end_hour:] = 0
battery_fast.discharge_array = bat_discharge_hours_fast
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)
@@ -348,12 +379,25 @@ class GeneticSimulation(PydanticBaseModel):
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
)
(
energy_feedin_grid_actual,
energy_consumption_grid_actual,
losses,
eigenverbrauch,
) = inverter_fast.process_energy(energy_produced, consumption, hour)
) = 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]
# AC PV Battery Charge
if battery_fast:
@@ -391,17 +435,26 @@ class GeneticSimulation(PydanticBaseModel):
)
# 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
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
hourly_electricity_price = elect_price_hourly_fast[hour]
electricity_price_per_hour[hour_idx] = hourly_electricity_price
feed_in_tariff_per_hour[hour_idx] = hourly_feed_in_tariff
# Financial calculations
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
revenue_per_hour[hour_idx] = (
energy_feedin_grid_actual * elect_revenue_per_hour_arr_fast[hour]
energy_feedin_grid_actual * hourly_feed_in_tariff
)
total_cost = np.nansum(costs_per_hour)
@@ -424,6 +477,7 @@ class GeneticSimulation(PydanticBaseModel):
"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,
}
@@ -448,6 +502,7 @@ class GeneticOptimization(OptimizationBase):
self.fix_seed = fixed_seed
self.optimize_ev = True
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
@@ -460,10 +515,42 @@ class GeneticOptimization(OptimizationBase):
# 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)
def decode_charge_discharge(
self, discharge_hours_bin: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Decode the input array into ac_charge, dc_charge, and discharge arrays."""
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Decode the input array into charge, self-consumption discharge and export arrays."""
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)
@@ -473,6 +560,7 @@ class GeneticOptimization(OptimizationBase):
# 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
# Idle states
idle_mask = (discharge_hours_bin_np >= 0) & (discharge_hours_bin_np < len_bat)
@@ -501,9 +589,14 @@ class GeneticOptimization(OptimizationBase):
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.
return ac_charge, dc_charge, discharge
return ac_charge, dc_charge, discharge, battery_grid_export
def mutate(self, individual: list[int]) -> tuple[list[int]]:
"""Custom mutation function for the individual."""
@@ -513,6 +606,8 @@ class GeneticOptimization(OptimizationBase):
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.config.prediction.hours]
@@ -652,10 +747,13 @@ class GeneticOptimization(OptimizationBase):
# 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)
@@ -711,11 +809,12 @@ class GeneticOptimization(OptimizationBase):
# Set start hour for appliance
self.simulation.home_appliance_start_hour = washingstart_int
ac_charge_hours, dc_charge_hours, discharge = self.decode_charge_discharge(
discharge_hours_bin
ac_charge_hours, dc_charge_hours, discharge, battery_grid_export = (
self.decode_charge_discharge(discharge_hours_bin)
)
self.simulation.bat_discharge_hours = discharge
self.simulation.bat_grid_export_hours = battery_grid_export
# Set DC charge hours only if DC optimization is enabled
if self.optimize_dc_charge:
self.simulation.dc_charge_hours = dc_charge_hours
@@ -1077,6 +1176,11 @@ class GeneticOptimization(OptimizationBase):
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
# Start hour has to be in sync with energy management
@@ -1094,10 +1198,6 @@ class GeneticOptimization(OptimizationBase):
generations = 400
logger.error("Generations not configured. Using {}.", generations)
einspeiseverguetung_euro_pro_wh = np.full(
self.config.prediction.hours, parameters.ems.einspeiseverguetung_euro_pro_wh
)
self.simulation.reset()
# Initialize PV and EV batteries
@@ -1195,6 +1295,7 @@ class GeneticOptimization(OptimizationBase):
inverter=inverter, # battery is part of inverter
ev=eauto,
home_appliance=dishwasher,
direct_marketing_enabled=direct_marketing_enabled,
)
# Setup the DEAP environment and optimization process
@@ -1240,6 +1341,11 @@ class GeneticOptimization(OptimizationBase):
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()
# Visualize the results in PDF
try:
@@ -1249,6 +1355,7 @@ class GeneticOptimization(OptimizationBase):
"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,
@@ -1270,6 +1377,7 @@ class GeneticOptimization(OptimizationBase):
"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": GeneticSimulationResult(**simulation_result),
"eauto_obj": self.simulation.ev,
@@ -330,33 +330,48 @@ class GeneticOptimizationParameters(
)
# Retry
continue
try:
feed_in_tariff_wh = cls.prediction.key_to_array(
key="feed_in_tariff_wh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
).tolist()
except:
logger.info(
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}.",
attempt,
)
cls.config.merge_settings_from_dict(
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": {
"feed_in_tariff_kwh": 0.078,
if cls.config.feedintariff.direct_marketing_enabled:
if cls.config.feedintariff.provider == "FeedInTariffEnergyCharts":
try:
feed_in_tariff_wh = cls.prediction.key_to_array(
key="feed_in_tariff_wh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
).tolist()
except:
feed_in_tariff_wh = list(elecprice_marketprice_wh)
else:
feed_in_tariff_wh = list(elecprice_marketprice_wh)
else:
try:
feed_in_tariff_wh = cls.prediction.key_to_array(
key="feed_in_tariff_wh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
).tolist()
except:
logger.info(
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}.",
attempt,
)
cls.config.merge_settings_from_dict(
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": {
"feed_in_tariff_kwh": 0.078,
},
},
},
},
}
)
# Retry
continue
}
)
# Retry
continue
try:
weather_temp_air = cls.prediction.key_to_array(
key="weather_temp_air",
@@ -130,6 +130,12 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
Electricity_price: list[float] = Field(
json_schema_extra={"description": "Used Electricity Price, including predictions"}
)
Feed_in_tariff: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": "Used feed-in tariff in €/Wh per hour, including predictions"
},
)
@field_validator(
"Last_Wh_pro_Stunde",
@@ -142,6 +148,7 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
"Verluste_Pro_Stunde",
"Home_appliance_wh_per_hour",
"Electricity_price",
"Feed_in_tariff",
mode="before",
)
def convert_numpy(cls, field: Any) -> Any:
@@ -163,7 +170,13 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
)
discharge_allowed: list[int] = Field(
json_schema_extra={
"description": "Array with discharge values (1 for discharge, 0 otherwise)."
"description": "Array with self-consumption discharge values (1 for discharge, 0 otherwise)."
}
)
battery_grid_export_allowed: list[int] = Field(
default_factory=list,
json_schema_extra={
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
}
)
eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
@@ -186,6 +199,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"ac_charge",
"dc_charge",
"discharge_allowed",
"battery_grid_export_allowed",
mode="before",
)
def convert_numpy(cls, field: Any) -> Any:
@@ -226,13 +240,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
ac_charge: float,
dc_charge: float,
discharge_allowed: bool,
battery_grid_export_allowed: bool = False,
) -> tuple[BatteryOperationMode, float]:
"""Maps low-level solution to a representative operation mode and factor.
Args:
ac_charge (float): Allowed AC-side charging power (relative units).
dc_charge (float): Allowed DC-side charging power (relative units).
discharge_allowed (bool): Whether discharging is permitted.
discharge_allowed (bool): Whether discharging to local load is permitted.
battery_grid_export_allowed (bool): Whether discharge into the grid is permitted.
Returns:
tuple[BatteryOperationMode, float]: A tuple containing
@@ -240,15 +256,28 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- `float`: the operation factor corresponding to the active signal.
Notes:
- The mapping prioritizes AC charge > DC charge > discharge.
- Explicit grid export is separate from local-load discharge.
- The mapping prioritizes export > AC charge > DC charge > discharge.
- Multiple strategies can produce the same low-level signals; this function
returns a representative mode based on a defined priority order.
"""
# (0,0,0) → Nothing allowed
if ac_charge <= 0.0 and dc_charge <= 0.0 and not discharge_allowed:
if (
ac_charge <= 0.0
and dc_charge <= 0.0
and not discharge_allowed
and not battery_grid_export_allowed
):
return BatteryOperationMode.IDLE, 1.0
# (0,0,1) → Discharge only
if battery_grid_export_allowed:
if ac_charge > 0.0 or dc_charge > 0.0:
raise ValueError(
"Illegal state: battery_grid_export_allowed cannot be combined with charging"
)
return BatteryOperationMode.GRID_SUPPORT_EXPORT, 1.0
# (0,0,1) -> Discharge for local load only
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
return BatteryOperationMode.PEAK_SHAVING, 1.0
@@ -289,7 +318,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
dc_charge: float,
discharge_allowed: bool,
soc_pct: float,
) -> tuple[float, float, bool]:
battery_grid_export_allowed: bool = False,
) -> tuple[float, float, bool, bool]:
"""Clamp raw genetic gene values by the battery's actual SOC at that hour.
The raw gene values represent the optimizer's *intent* and are stored
@@ -305,10 +335,11 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- DC charge factor (PV): zeroed when battery is at or above max SOC
(the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC.
- Battery grid export: blocked when SOC is at or below min SOC.
"""
bat_list = self.config.devices.batteries
if not bat_list:
return ac_charge, dc_charge, discharge_allowed
return ac_charge, dc_charge, discharge_allowed, battery_grid_export_allowed
bat = bat_list[0]
min_soc = float(bat.min_soc_percentage)
@@ -341,19 +372,22 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
# --- Discharge: block at min SOC ---
effective_dis = discharge_allowed and (soc_pct > min_soc)
effective_grid_export = battery_grid_export_allowed and (soc_pct > min_soc)
return effective_ac, effective_dc, effective_dis
return effective_ac, effective_dc, effective_dis, effective_grid_export
def optimization_solution(self) -> OptimizationSolution:
"""Provide the genetic solution as a general optimization solution.
The battery modes are controlled by the grid control triggers:
- ac_charge: charge from grid
- discharge_allowed: discharge to grid
- discharge_allowed: discharge to local load
- battery_grid_export_allowed: discharge to grid
The following battery modes are supported:
- SELF_CONSUMPTION: ac_charge == 0 and discharge_allowed == 0
- GRID_SUPPORT_EXPORT: ac_charge == 0 and discharge_allowed == 1
- SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1
- PEAK_SHAVING: ac_charge == 0 and discharge_allowed == 1
- GRID_SUPPORT_EXPORT: battery_grid_export_allowed == 1
- GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
"""
start_datetime = get_ems().start_datetime
@@ -407,6 +441,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"genetic_ac_charge_factor": [],
"genetic_dc_charge_factor": [],
"genetic_discharge_allowed_factor": [],
"genetic_battery_grid_export_allowed_factor": [],
}
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
for hour_idx, rate in enumerate(self.ac_charge):
@@ -417,11 +452,19 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
ac_charge_hour = self.ac_charge[hour_idx]
dc_charge_hour = self.dc_charge[hour_idx]
discharge_allowed_hour = bool(self.discharge_allowed[hour_idx])
battery_grid_export_allowed_hour = (
bool(self.battery_grid_export_allowed[hour_idx])
if hour_idx < len(self.battery_grid_export_allowed)
else False
)
# Raw genetic gene values — optimizer intent, stored verbatim
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
operation["genetic_battery_grid_export_allowed_factor"].append(
float(battery_grid_export_allowed_hour)
)
# SOC-clamped effective values — what can physically be executed at
# this hour given the expected battery state of charge.
@@ -431,11 +474,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
if result_idx < len(self.result.akku_soc_pro_stunde)
else 0.0
)
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors(
ac_charge_hour, dc_charge_hour, discharge_allowed_hour, soc_h_pct
eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors(
ac_charge_hour,
dc_charge_hour,
discharge_allowed_hour,
soc_h_pct,
battery_grid_export_allowed_hour,
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac, eff_dc, eff_dis
eff_ac, eff_dc, eff_dis, eff_grid_export
)
for mode in BatteryOperationMode:
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
@@ -671,14 +718,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
if result_idx < len(self.result.akku_soc_pro_stunde)
else 0.0
)
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors(
battery_grid_export_allowed_hour = (
bool(self.battery_grid_export_allowed[hour_idx])
if hour_idx < len(self.battery_grid_export_allowed)
else False
)
eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors(
self.ac_charge[hour_idx],
self.dc_charge[hour_idx],
bool(self.discharge_allowed[hour_idx]),
soc_h_pct,
battery_grid_export_allowed_hour,
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac, eff_dc, eff_dis
eff_ac, eff_dc, eff_dis, eff_grid_export
)
if (
operation_mode == last_operation_mode
+22 -2
View File
@@ -5,6 +5,9 @@ from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.prediction.feedintariffenergycharts import (
FeedInTariffEnergyChartsCommonSettings,
)
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
@@ -16,7 +19,7 @@ def elecprice_provider_ids() -> list[str]:
except:
# Prediction may not be initialized
# Return at least provider used in example
return ["FeedInTariffFixed", "FeedInTarifImport"]
return ["FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport"]
return [
provider.provider_id()
@@ -32,6 +35,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
default=None,
json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [None]},
)
FeedInTariffEnergyCharts: Optional[FeedInTariffEnergyChartsCommonSettings] = Field(
default=None,
json_schema_extra={"description": "FeedInTariffEnergyCharts settings", "examples": [None]},
)
FeedInTariffImport: Optional[FeedInTariffImportCommonSettings] = Field(
default=None,
json_schema_extra={"description": "FeedInTariffImport settings", "examples": [None]},
@@ -41,11 +48,23 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
class FeedInTariffCommonSettings(SettingsBaseModel):
"""Feed In Tariff Prediction Configuration."""
direct_marketing_enabled: bool = Field(
default=False,
json_schema_extra={
"description": "Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization.",
"examples": [False, True],
},
)
provider: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Feed in tariff provider id of provider to be used.",
"examples": ["FeedInTariffFixed", "FeedInTarifImport"],
"examples": [
"FeedInTariffFixed",
"FeedInTariffEnergyCharts",
"FeedInTariffImport",
],
},
)
@@ -57,6 +76,7 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
# Example 1: Empty/default settings (all providers None)
{
"FeedInTariffFixed": None,
"FeedInTariffEnergyCharts": None,
"FeedInTariffImport": None,
},
],
@@ -0,0 +1,179 @@
"""Provides feed-in tariff data from Energy-Charts market prices."""
from datetime import datetime
from typing import Optional
import pandas as pd
import requests
from loguru import logger
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsBiddingZones,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
class FeedInTariffEnergyChartsCommonSettings(SettingsBaseModel):
"""Common settings for Energy-Charts feed-in tariff provider."""
bidding_zone: EnergyChartsBiddingZones = Field(
default=EnergyChartsBiddingZones.DE_LU,
json_schema_extra={
"description": (
"Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', "
"'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI'"
),
"examples": ["DE-LU"],
},
)
class FeedInTariffEnergyCharts(FeedInTariffProvider):
"""Fetch Energy-Charts market prices as feed-in tariff data.
This provider stores the raw Energy-Charts day-ahead market price as
``feed_in_tariff_wh``. Unlike ``ElecPriceEnergyCharts`` it intentionally
does not add electricity import charges or VAT.
"""
highest_orig_datetime: Optional[datetime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the Energy-Charts feed-in tariff provider."""
return "FeedInTariffEnergyCharts"
def _bidding_zone(self) -> str:
settings = self.config.feedintariff.provider_settings.FeedInTariffEnergyCharts
if settings is None:
return EnergyChartsBiddingZones.DE_LU.value
bidding_zone = settings.bidding_zone
if isinstance(bidding_zone, EnergyChartsBiddingZones):
return bidding_zone.value
return str(bidding_zone)
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
"""Fetch market price forecast data from Energy-Charts."""
source = "https://api.energy-charts.info"
if start_date is None:
start_date = to_datetime(
self.ems_start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
)
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
url = f"{source}/price?bzn={self._bidding_zone()}&start={start_date}&end={last_date}"
response = requests.get(url, timeout=30)
logger.debug(f"Response from {url}: {response}")
response.raise_for_status()
energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return energy_charts_data
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
series_data = pd.Series(dtype=float)
for unix_sec, price_eur_per_mwh in zip(
energy_charts_data.unix_seconds, energy_charts_data.price, strict=False
):
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
return series_data
def _predict_prices(self, history, hours: int):
energycharts = ElecPriceEnergyCharts()
amount_datasets = len(self.records)
if amount_datasets > 800:
return energycharts._predict_ets(history, seasonal_periods=168, hours=hours)
if amount_datasets > 168:
return energycharts._predict_ets(history, seasonal_periods=24, hours=hours)
if amount_datasets > 0:
return energycharts._predict_median(history, hours=hours)
logger.error("No feed-in tariff data available for Energy-Charts prediction")
raise ValueError("No data available")
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update feed-in tariff forecast data from Energy-Charts."""
now = pd.Timestamp.now(tz=self.config.general.timezone)
midnight = now.normalize()
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
end = midnight + pd.Timedelta(hours=hours_ahead)
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
past_days = 35
if self.highest_orig_datetime:
history_series = self.key_to_series(
key="feed_in_tariff_wh", start_datetime=self.ems_start_datetime
)
if not history_series.empty and history_series.index.min() <= self.ems_start_datetime:
past_days = 0
needs_update = end > self.highest_orig_datetime
else:
needs_update = True
if needs_update:
logger.info(
"Update FeedInTariffEnergyCharts is needed, last in history: {}",
self.highest_orig_datetime,
)
start_date = to_datetime(
self.ems_start_datetime - to_duration(f"{past_days} days"),
as_string="YYYY-MM-DD",
)
energy_charts_data = self._request_forecast(
start_date=start_date, force_update=force_update
)
series_data = self._parse_data(energy_charts_data)
if series_data.empty:
raise ValueError("No Energy-Charts feed-in tariff data available")
self.highest_orig_datetime = series_data.index.max()
self.key_from_series("feed_in_tariff_wh", series_data)
else:
logger.info(
"No update FeedInTariffEnergyCharts is needed, last in history: {}",
self.highest_orig_datetime,
)
history = self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
if not self.highest_orig_datetime:
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
)
if needed_hours <= 0:
logger.warning(
"No feed-in tariff prediction needed. needed_hours={}, hours={}, "
"highest_orig_datetime={}, start_datetime={}",
needed_hours,
self.config.prediction.hours,
self.highest_orig_datetime,
self.ems_start_datetime,
)
return
prediction = self._predict_prices(history, needed_hours)
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
for i in range(len(prediction))
],
)
self.key_from_series("feed_in_tariff_wh", prediction_series)
@@ -36,6 +36,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import (
@@ -81,6 +82,8 @@ elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_tibber = ElecPriceTibber()
elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport()
loadforecast_akkudoktor = LoadAkkudoktor()
@@ -106,6 +109,8 @@ def prediction_providers() -> list[
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
LoadAkkudoktor,
@@ -134,6 +139,8 @@ def prediction_providers() -> list[
elecprice_tibber, \
elecprice_fixed, \
elecprice_import, \
elecprice_tibber, \
feedintariff_energy_charts, \
feedintariff_fixed, \
feedintariff_import, \
loadforecast_akkudoktor, \
@@ -158,6 +165,8 @@ def prediction_providers() -> list[
elecprice_tibber,
elecprice_fixed,
elecprice_import,
elecprice_tibber,
feedintariff_energy_charts,
feedintariff_fixed,
feedintariff_import,
loadforecast_akkudoktor,
@@ -187,6 +196,8 @@ class Prediction(PredictionContainer):
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
LoadAkkudoktor,
+19 -7
View File
@@ -549,17 +549,29 @@ def prepare_visualize(
)
labels = labels[start_hour:] + labels
charge_discharge_series = [
results["ac_charge"][start_hour:],
results["dc_charge"][start_hour:],
results["discharge_allowed"][start_hour:],
]
charge_discharge_labels = [
"AC Charging (relative)",
"DC Charging (relative)",
"Discharge Allowed",
]
charge_discharge_colors = ["blue", "green", "red"]
if results.get("battery_grid_export_allowed"):
charge_discharge_series.append(results["battery_grid_export_allowed"][start_hour:])
charge_discharge_labels.append("Battery Grid Export Allowed")
charge_discharge_colors.append("purple")
report.create_bar_chart(
labels,
[
results["ac_charge"][start_hour:],
results["dc_charge"][start_hour:],
results["discharge_allowed"][start_hour:],
],
charge_discharge_series,
title="AC/DC Charging and Discharge Overview",
ylabel="Relative Power (0-1) / Discharge (0 or 1)",
label_names=["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"],
colors=["blue", "green", "red"],
label_names=charge_discharge_labels,
colors=charge_discharge_colors,
bottom=3,
xlabels=labels,
)