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:11 +02:00
parent cc583600d8
commit 7f2ac9098c
19 changed files with 960 additions and 72 deletions

View File

@@ -122,9 +122,11 @@
"mode": "OPTIMIZATION"
},
"feedintariff": {
"direct_marketing_enabled": false,
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null
}
},

View File

@@ -7,6 +7,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| direct_marketing_enabled | `EOS_FEEDINTARIFF__DIRECT_MARKETING_ENABLED` | `bool` | `rw` | `False` | Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization. |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
| provider_settings | `EOS_FEEDINTARIFF__PROVIDER_SETTINGS` | `FeedInTariffCommonProviderSettings` | `rw` | `required` | Provider settings |
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
@@ -21,9 +22,11 @@
```json
{
"feedintariff": {
"direct_marketing_enabled": false,
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null
}
}
@@ -39,12 +42,15 @@
```json
{
"feedintariff": {
"direct_marketing_enabled": false,
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null
},
"providers": [
"FeedInTariffEnergyCharts",
"FeedInTariffFixed",
"FeedInTariffImport"
]
@@ -86,6 +92,37 @@
```
<!-- pyml enable line-length -->
### Common settings for Energy-Charts feed-in tariff provider
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings::FeedInTariffEnergyCharts
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| bidding_zone | `<enum 'EnergyChartsBiddingZones'>` | `rw` | `DE-LU` | Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI' |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffEnergyCharts": {
"bidding_zone": "DE-LU"
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecprice fixed price
<!-- pyml disable line-length -->
@@ -126,6 +163,7 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| FeedInTariffEnergyCharts | `Optional[akkudoktoreos.prediction.feedintariffenergycharts.FeedInTariffEnergyChartsCommonSettings]` | `rw` | `None` | FeedInTariffEnergyCharts settings |
| FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings |
| FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings |
:::
@@ -141,6 +179,7 @@
"feedintariff": {
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null
}
}

View File

@@ -262,6 +262,7 @@ smaller values (e.g. `0.0`) disable the penalty entirely.
"ac_charge": [0.625, 0, ..., 0.75, 0],
"dc_charge": [1, 1, ..., 1, 1],
"discharge_allowed": [0, 0, 1, ..., 0, 0],
"battery_grid_export_allowed": [0, 0, 0, ..., 1, 0],
"eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
"result": {
"Last_Wh_pro_Stunde": [...],
@@ -282,7 +283,8 @@ smaller values (e.g. `0.0`) disable the penalty entirely.
- `ac_charge`: Grid charging schedule (0.0-1.0)
- `dc_charge`: DC charging schedule (0-1)
- `discharge_allowed`: Discharge permission (0 or 1)
- `discharge_allowed`: Battery discharge permission for local self-consumption/load coverage (0 or 1)
- `battery_grid_export_allowed`: Battery discharge permission for grid export/direct marketing (0 or 1)
0 (no charge)
1 (charge with full load)

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "v0.3.0.dev2607071966322885"
"version": "v0.3.0.dev2607101210858200"
},
"paths": {
"/v1/admin/cache/clear": {
@@ -3621,7 +3621,7 @@
}
],
"title": "Home Id",
"description": "Tibber home id to read prices from.",
"description": "Optional Tibber home id. If omitted, the first home with a subscription is used.",
"examples": [
"00000000-0000-0000-0000-000000000000"
]
@@ -4283,6 +4283,20 @@
null
]
},
"FeedInTariffEnergyCharts": {
"anyOf": [
{
"$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings"
},
{
"type": "null"
}
],
"description": "FeedInTariffEnergyCharts settings",
"examples": [
null
]
},
"FeedInTariffImport": {
"anyOf": [
{
@@ -4304,6 +4318,16 @@
},
"FeedInTariffCommonSettings-Input": {
"properties": {
"direct_marketing_enabled": {
"type": "boolean",
"title": "Direct Marketing Enabled",
"description": "Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization.",
"default": false,
"examples": [
false,
true
]
},
"provider": {
"anyOf": [
{
@@ -4317,7 +4341,8 @@
"description": "Feed in tariff provider id of provider to be used.",
"examples": [
"FeedInTariffFixed",
"FeedInTarifImport"
"FeedInTariffEnergyCharts",
"FeedInTariffImport"
]
},
"provider_settings": {
@@ -4334,6 +4359,16 @@
},
"FeedInTariffCommonSettings-Output": {
"properties": {
"direct_marketing_enabled": {
"type": "boolean",
"title": "Direct Marketing Enabled",
"description": "Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization.",
"default": false,
"examples": [
false,
true
]
},
"provider": {
"anyOf": [
{
@@ -4347,7 +4382,8 @@
"description": "Feed in tariff provider id of provider to be used.",
"examples": [
"FeedInTariffFixed",
"FeedInTarifImport"
"FeedInTariffEnergyCharts",
"FeedInTariffImport"
]
},
"provider_settings": {
@@ -4374,6 +4410,21 @@
"title": "FeedInTariffCommonSettings",
"description": "Feed In Tariff Prediction Configuration."
},
"FeedInTariffEnergyChartsCommonSettings": {
"properties": {
"bidding_zone": {
"$ref": "#/components/schemas/EnergyChartsBiddingZones",
"description": "Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI'",
"default": "DE-LU",
"examples": [
"DE-LU"
]
}
},
"type": "object",
"title": "FeedInTariffEnergyChartsCommonSettings",
"description": "Common settings for Energy-Charts feed-in tariff provider."
},
"FeedInTariffFixedCommonSettings": {
"properties": {
"feed_in_tariff_kwh": {
@@ -5107,7 +5158,15 @@
},
"type": "array",
"title": "Discharge Allowed",
"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": {
"items": {
"type": "integer"
},
"type": "array",
"title": "Battery Grid Export Allowed",
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
},
"eautocharge_hours_float": {
"anyOf": [

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

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,

View File

@@ -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",

View File

@@ -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

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,
},
],

View File

@@ -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)

View File

@@ -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,

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,
)

View File

@@ -0,0 +1,77 @@
# ruff: noqa: S101
import json
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON = DIR_TESTDATA.joinpath(
"elecpriceforecast_energycharts.json"
)
@pytest.fixture
def sample_energycharts_json():
with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open(
"r", encoding="utf-8", newline=None
) as f_res:
return json.load(f_res)
@pytest.fixture
def provider(config_eos):
config_eos.merge_settings_from_dict(
{
"elecprice": {
"charges_kwh": 0.21,
"energycharts": {"bidding_zone": "DE-LU"},
},
"feedintariff": {
"provider": "FeedInTariffEnergyCharts",
"provider_settings": {
"FeedInTariffEnergyCharts": {"bidding_zone": "AT"},
},
},
}
)
provider = FeedInTariffEnergyCharts()
provider.highest_orig_datetime = None
provider.records.clear()
assert provider.enabled()
return provider
def test_provider_is_available(config_eos):
assert "FeedInTariffEnergyCharts" in config_eos.feedintariff.providers
def test_parse_data_uses_raw_market_price(provider, sample_energycharts_json):
energy_charts_data = EnergyChartsElecPrice.model_validate(sample_energycharts_json)
series = provider._parse_data(energy_charts_data)
assert series.iloc[0] == pytest.approx(sample_energycharts_json["price"][0] / 1_000_000)
@patch("requests.get")
def test_request_forecast_uses_feedintariff_bidding_zone(
mock_get, provider, sample_energycharts_json
):
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_energycharts_json)
mock_get.return_value = mock_response
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
provider._request_forecast(start_date="2024-12-10", force_update=True)
actual_url = mock_get.call_args[0][0]
assert "bzn=AT" in actual_url

View File

@@ -57,6 +57,16 @@ def test_invalid_provider(provider, config_eos):
config_eos.merge_settings_from_dict(settings)
def test_direct_marketing_switch(config_eos):
assert config_eos.feedintariff.direct_marketing_enabled is False
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
assert config_eos.feedintariff.direct_marketing_enabled is True
# ------------------------------------------------
# Fixed feed in tariv values
# ------------------------------------------------

View File

@@ -37,6 +37,51 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
assert actual[key] == pytest.approx(value)
def test_direct_marketing_uses_market_price_as_feed_in_tariff(config_eos: ConfigEOS):
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0, 0.0],
"strompreis_euro_pro_wh": [0.0002, -0.0001],
"einspeiseverguetung_euro_pro_wh": [0.00007, 0.00007],
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [0.0, 0.0],
},
pv_akku=None,
inverter=None,
eauto=None,
)
adjusted = GeneticOptimization()._parameters_for_config(parameters)
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0002, -0.0001]
assert parameters.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007]
def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS):
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0, 0.0],
"strompreis_euro_pro_wh": [0.0002, 0.0003],
"einspeiseverguetung_euro_pro_wh": [0.0001, -0.00005],
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [0.0, 0.0],
},
pv_akku=None,
inverter=None,
eauto=None,
)
adjusted = GeneticOptimization()._parameters_for_config(parameters)
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
@pytest.mark.parametrize(
"fn_in, fn_out, ngen, break_even",
[

View File

@@ -1,3 +1,5 @@
from unittest.mock import Mock
import numpy as np
import pytest
@@ -249,6 +251,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
assert simulation.ac_charge_hours is not None
assert simulation.dc_charge_hours is not None
assert simulation.bat_discharge_hours is not None
assert simulation.bat_grid_export_hours is not None
assert simulation.ev_charge_hours is not None
simulation.ac_charge_hours[start_hour] = 1.0
simulation.dc_charge_hours[start_hour] = 1.0
@@ -374,3 +377,101 @@ def test_simulation(genetic_simulation):
), "The sum of 'Home_appliance_wh_per_hour' should be 2000."
print("All tests passed successfully.")
def test_direct_marketing_curtails_negative_feed_in(config_eos):
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}}
)
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0))
inverter.self_consumption_predictor.calculate_self_consumption = Mock(return_value=1.0)
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters(
pv_prognose_wh=[500.0, 500.0],
strompreis_euro_pro_wh=[-0.0001, -0.0001],
einspeiseverguetung_euro_pro_wh=[-0.0001, -0.0001],
preis_euro_pro_wh_akku=0.0,
gesamtlast=[0.0, 0.0],
),
optimization_hours=config_eos.optimization.horizon_hours,
prediction_hours=config_eos.prediction.hours,
inverter=inverter,
direct_marketing_enabled=True,
)
result = simulation.simulate(start_hour=0)
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0
assert result["Einnahmen_Euro_pro_Stunde"][0] == 0.0
assert result["Verluste_Pro_Stunde"][0] == pytest.approx(500.0)
def _direct_marketing_battery_export_simulation(config_eos) -> GeneticSimulation:
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}}
)
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=1000,
initial_soc_percentage=100,
min_soc_percentage=0,
charging_efficiency=1.0,
discharging_efficiency=1.0,
max_charge_power_w=500,
),
prediction_hours=config_eos.prediction.hours,
)
inverter = Inverter(
InverterParameters(
device_id="inverter1",
max_power_wh=500.0,
battery_id=battery.parameters.device_id,
),
battery=battery,
)
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters(
pv_prognose_wh=[0.0, 0.0],
strompreis_euro_pro_wh=[0.0, 0.0],
einspeiseverguetung_euro_pro_wh=[0.0002, 0.0002],
preis_euro_pro_wh_akku=0.0,
gesamtlast=[0.0, 0.0],
),
optimization_hours=config_eos.optimization.horizon_hours,
prediction_hours=config_eos.prediction.hours,
inverter=inverter,
direct_marketing_enabled=True,
)
return simulation
def test_direct_marketing_discharge_allowed_does_not_export_battery(config_eos):
simulation = _direct_marketing_battery_export_simulation(config_eos)
assert simulation.bat_discharge_hours is not None
simulation.bat_discharge_hours[0] = 1
result = simulation.simulate(start_hour=0)
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0
assert simulation.battery is not None
assert simulation.battery.current_soc_percentage() == 100.0
def test_direct_marketing_battery_grid_export_uses_separate_signal(config_eos):
simulation = _direct_marketing_battery_export_simulation(config_eos)
assert simulation.bat_grid_export_hours is not None
simulation.bat_grid_export_hours[0] = 1
result = simulation.simulate(start_hour=0)
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(500.0)
assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.1)
assert simulation.battery is not None
assert simulation.battery.current_soc_percentage() == 50.0

View File

@@ -0,0 +1,56 @@
# ruff: noqa: S101
import numpy as np
from akkudoktoreos.devices.devicesabc import BatteryOperationMode
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
def test_battery_discharge_allowed_remains_local_load_mode(config_eos):
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
solution = GeneticSolution.model_construct()
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
ac_charge=0.0,
dc_charge=0.0,
discharge_allowed=True,
)
assert operation_mode == BatteryOperationMode.PEAK_SHAVING
assert operation_mode_factor == 1.0
def test_battery_grid_export_signal_maps_to_grid_support_export(config_eos):
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
solution = GeneticSolution.model_construct()
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
ac_charge=0.0,
dc_charge=0.0,
discharge_allowed=False,
battery_grid_export_allowed=True,
)
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
assert operation_mode_factor == 1.0
def test_decode_charge_discharge_has_separate_battery_grid_export_state():
optimization = GeneticOptimization()
optimization.bat_possible_charge_values = [1.0]
optimization.optimize_dc_charge = True
optimization.optimize_battery_grid_export = True
ac_charge, dc_charge, discharge, battery_grid_export = (
optimization.decode_charge_discharge(np.array([5]))
)
assert ac_charge.tolist() == [0.0]
assert dc_charge.tolist() == [0]
assert discharge.tolist() == [0]
assert battery_grid_export.tolist() == [1]

View File

@@ -1,4 +1,4 @@
from unittest.mock import Mock, patch
from unittest.mock import Mock, call, patch
import pytest
@@ -123,6 +123,25 @@ def test_process_energy_battery_discharges(inverter, mock_battery):
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
def test_process_energy_allows_battery_grid_export(inverter, mock_battery):
mock_battery.max_charge_power_w = 300.0
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (200.0, 0.0)]
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation=0.0,
consumption=100.0,
hour=12,
allow_battery_grid_export=True,
)
assert grid_export == pytest.approx(200.0, rel=1e-2)
assert grid_import == 0.0
assert losses == 0.0
assert self_consumption == 100.0
mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(200.0, 12)])
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
def test_process_energy_battery_empty(inverter, mock_battery):
# Battery is empty, so no energy can be discharged
mock_battery.discharge_energy.return_value = (0.0, 0.0)

View File

@@ -0,0 +1,36 @@
import pandas as pd
import pytest
from akkudoktoreos.server import eos as eos_server
class _FakeEms:
async def run(self, **kwargs):
return None
class _FakePrediction:
def __init__(self):
self.key_to_series_kwargs = None
def key_to_series(self, **kwargs):
self.key_to_series_kwargs = kwargs
start = pd.Timestamp(kwargs["start_datetime"].isoformat())
index = pd.date_range(start=start, periods=8, freq="15min")
values = [1.0, 3.0, 5.0, 7.0, 10.0, 14.0, 18.0, 22.0]
return pd.Series(values, index=index, name=kwargs["key"])
@pytest.mark.asyncio
async def test_strompreis_endpoint_averages_quarter_hour_prices(monkeypatch, config_eos):
"""Deprecated /strompreis aggregates 15-minute spot prices to hourly means."""
prediction = _FakePrediction()
monkeypatch.setattr(eos_server, "get_ems", lambda: _FakeEms())
monkeypatch.setattr(eos_server, "get_prediction", lambda: prediction)
result = await eos_server.fastapi_strompreis()
assert result[:3] == [4.0, 16.0, 16.0]
assert len(result) == 48
assert prediction.key_to_series_kwargs["key"] == "elecprice_marketprice_wh"