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" "mode": "OPTIMIZATION"
}, },
"feedintariff": { "feedintariff": {
"direct_marketing_enabled": false,
"provider": "FeedInTariffFixed", "provider": "FeedInTariffFixed",
"provider_settings": { "provider_settings": {
"FeedInTariffFixed": null, "FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null "FeedInTariffImport": null
} }
}, },

View File

@@ -7,6 +7,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description | | 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 | `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 | | provider_settings | `EOS_FEEDINTARIFF__PROVIDER_SETTINGS` | `FeedInTariffCommonProviderSettings` | `rw` | `required` | Provider settings |
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. | | providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
@@ -21,9 +22,11 @@
```json ```json
{ {
"feedintariff": { "feedintariff": {
"direct_marketing_enabled": false,
"provider": "FeedInTariffFixed", "provider": "FeedInTariffFixed",
"provider_settings": { "provider_settings": {
"FeedInTariffFixed": null, "FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null "FeedInTariffImport": null
} }
} }
@@ -39,12 +42,15 @@
```json ```json
{ {
"feedintariff": { "feedintariff": {
"direct_marketing_enabled": false,
"provider": "FeedInTariffFixed", "provider": "FeedInTariffFixed",
"provider_settings": { "provider_settings": {
"FeedInTariffFixed": null, "FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": null "FeedInTariffImport": null
}, },
"providers": [ "providers": [
"FeedInTariffEnergyCharts",
"FeedInTariffFixed", "FeedInTariffFixed",
"FeedInTariffImport" "FeedInTariffImport"
] ]
@@ -86,6 +92,37 @@
``` ```
<!-- pyml enable line-length --> <!-- 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 ### Common settings for elecprice fixed price
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
@@ -126,6 +163,7 @@
| Name | Type | Read-Only | Default | Description | | 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 | | FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings |
| FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings | | FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings |
::: :::
@@ -141,6 +179,7 @@
"feedintariff": { "feedintariff": {
"provider_settings": { "provider_settings": {
"FeedInTariffFixed": null, "FeedInTariffFixed": null,
"FeedInTariffEnergyCharts": null,
"FeedInTariffImport": 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], "ac_charge": [0.625, 0, ..., 0.75, 0],
"dc_charge": [1, 1, ..., 1, 1], "dc_charge": [1, 1, ..., 1, 1],
"discharge_allowed": [0, 0, 1, ..., 0, 0], "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], "eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
"result": { "result": {
"Last_Wh_pro_Stunde": [...], "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) - `ac_charge`: Grid charging schedule (0.0-1.0)
- `dc_charge`: DC charging schedule (0-1) - `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) 0 (no charge)
1 (charge with full load) 1 (charge with full load)

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0", "name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html" "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
}, },
"version": "v0.3.0.dev2607071966322885" "version": "v0.3.0.dev2607101210858200"
}, },
"paths": { "paths": {
"/v1/admin/cache/clear": { "/v1/admin/cache/clear": {
@@ -3621,7 +3621,7 @@
} }
], ],
"title": "Home Id", "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": [ "examples": [
"00000000-0000-0000-0000-000000000000" "00000000-0000-0000-0000-000000000000"
] ]
@@ -4283,6 +4283,20 @@
null null
] ]
}, },
"FeedInTariffEnergyCharts": {
"anyOf": [
{
"$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings"
},
{
"type": "null"
}
],
"description": "FeedInTariffEnergyCharts settings",
"examples": [
null
]
},
"FeedInTariffImport": { "FeedInTariffImport": {
"anyOf": [ "anyOf": [
{ {
@@ -4304,6 +4318,16 @@
}, },
"FeedInTariffCommonSettings-Input": { "FeedInTariffCommonSettings-Input": {
"properties": { "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": { "provider": {
"anyOf": [ "anyOf": [
{ {
@@ -4317,7 +4341,8 @@
"description": "Feed in tariff provider id of provider to be used.", "description": "Feed in tariff provider id of provider to be used.",
"examples": [ "examples": [
"FeedInTariffFixed", "FeedInTariffFixed",
"FeedInTarifImport" "FeedInTariffEnergyCharts",
"FeedInTariffImport"
] ]
}, },
"provider_settings": { "provider_settings": {
@@ -4334,6 +4359,16 @@
}, },
"FeedInTariffCommonSettings-Output": { "FeedInTariffCommonSettings-Output": {
"properties": { "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": { "provider": {
"anyOf": [ "anyOf": [
{ {
@@ -4347,7 +4382,8 @@
"description": "Feed in tariff provider id of provider to be used.", "description": "Feed in tariff provider id of provider to be used.",
"examples": [ "examples": [
"FeedInTariffFixed", "FeedInTariffFixed",
"FeedInTarifImport" "FeedInTariffEnergyCharts",
"FeedInTariffImport"
] ]
}, },
"provider_settings": { "provider_settings": {
@@ -4374,6 +4410,21 @@
"title": "FeedInTariffCommonSettings", "title": "FeedInTariffCommonSettings",
"description": "Feed In Tariff Prediction Configuration." "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": { "FeedInTariffFixedCommonSettings": {
"properties": { "properties": {
"feed_in_tariff_kwh": { "feed_in_tariff_kwh": {
@@ -5107,7 +5158,15 @@
}, },
"type": "array", "type": "array",
"title": "Discharge Allowed", "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": { "eautocharge_hours_float": {
"anyOf": [ "anyOf": [

View File

@@ -30,8 +30,23 @@ class Inverter:
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency
self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w 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( 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]: ) -> tuple[float, float, float, float]:
losses = 0.0 losses = 0.0
grid_export = 0.0 grid_export = 0.0
@@ -59,6 +74,7 @@ class Inverter:
# Remaining load Self Consumption not perfect # Remaining load Self Consumption not perfect
remaining_load_evq = (generation - consumption) * (1.0 - scr) remaining_load_evq = (generation - consumption) * (1.0 - scr)
from_battery_dc = 0.0
if remaining_load_evq > 0: if remaining_load_evq > 0:
# Akku muss den Restverbrauch decken # Akku muss den Restverbrauch decken
if self.battery: if self.battery:
@@ -105,6 +121,20 @@ class Inverter:
consumption + from_battery_ac consumption + from_battery_ac
) # Self-consumption is equal to the load ) # 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: else:
# Case 2: Insufficient generation, cover shortfall # Case 2: Insufficient generation, cover shortfall
shortfall = consumption - generation shortfall = consumption - generation
@@ -129,4 +159,18 @@ class Inverter:
grid_import = shortfall - battery_discharge_ac grid_import = shortfall - battery_discharge_ac
self_consumption = generation + 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 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." "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."}) battery: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
ev: 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( home_appliance: Optional[HomeAppliance] = Field(
@@ -93,6 +98,12 @@ class GeneticSimulation(PydanticBaseModel):
bat_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field( bat_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None, json_schema_extra={"description": "TBD"} 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( ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None, json_schema_extra={"description": "TBD"} default=None, json_schema_extra={"description": "TBD"}
) )
@@ -112,6 +123,7 @@ class GeneticSimulation(PydanticBaseModel):
ev: Optional[Battery] = None, ev: Optional[Battery] = None,
home_appliance: Optional[HomeAppliance] = None, home_appliance: Optional[HomeAppliance] = None,
inverter: Optional[Inverter] = None, inverter: Optional[Inverter] = None,
direct_marketing_enabled: bool = False,
) -> None: ) -> None:
"""Prepare simulation runs. """Prepare simulation runs.
@@ -119,13 +131,14 @@ class GeneticSimulation(PydanticBaseModel):
""" """
self.optimization_hours = optimization_hours self.optimization_hours = optimization_hours
self.prediction_hours = prediction_hours self.prediction_hours = prediction_hours
self.direct_marketing_enabled = direct_marketing_enabled
# Load arrays from provided EMS parameters # Load arrays from provided EMS parameters
self.load_energy_array = np.array(parameters.gesamtlast, float) self.load_energy_array = np.array(parameters.gesamtlast, float)
self.pv_prediction_wh = np.array(parameters.pv_prognose_wh, 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_price_hourly = np.array(parameters.strompreis_euro_pro_wh, float)
self.elect_revenue_per_hour_arr = ( 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) if isinstance(parameters.einspeiseverguetung_euro_pro_wh, list)
else np.full( else np.full(
len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float 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.ac_charge_hours = np.full(self.prediction_hours, 0.0)
self.dc_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_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_charge_hours = np.full(self.prediction_hours, 0.0)
self.ev_discharge_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 self.home_appliance_start_hour = None
@@ -172,6 +186,7 @@ class GeneticSimulation(PydanticBaseModel):
ac_charge_hours_fast = self.ac_charge_hours ac_charge_hours_fast = self.ac_charge_hours
dc_charge_hours_fast = self.dc_charge_hours dc_charge_hours_fast = self.dc_charge_hours
bat_discharge_hours_fast = self.bat_discharge_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_price_hourly_fast = self.elect_price_hourly
elect_revenue_per_hour_arr_fast = self.elect_revenue_per_hour_arr elect_revenue_per_hour_arr_fast = self.elect_revenue_per_hour_arr
pv_prediction_wh_fast = self.pv_prediction_wh pv_prediction_wh_fast = self.pv_prediction_wh
@@ -179,6 +194,7 @@ class GeneticSimulation(PydanticBaseModel):
ev_fast = self.ev ev_fast = self.ev
home_appliance_fast = self.home_appliance home_appliance_fast = self.home_appliance
inverter_fast = self.inverter inverter_fast = self.inverter
direct_marketing_enabled_fast = self.direct_marketing_enabled
# Check for simulation integrity (in a way that mypy understands) # Check for simulation integrity (in a way that mypy understands)
if ( if (
@@ -190,6 +206,7 @@ class GeneticSimulation(PydanticBaseModel):
or dc_charge_hours_fast is None or dc_charge_hours_fast is None
or elect_revenue_per_hour_arr_fast is None or elect_revenue_per_hour_arr_fast is None
or bat_discharge_hours_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 or ev_discharge_hours_fast is None
): ):
missing = [] missing = []
@@ -209,6 +226,8 @@ class GeneticSimulation(PydanticBaseModel):
missing.append("Electricity Revenue Per Hour") missing.append("Electricity Revenue Per Hour")
if bat_discharge_hours_fast is None: if bat_discharge_hours_fast is None:
missing.append("Battery Discharge Hours") 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: if ev_discharge_hours_fast is None:
missing.append("EV Discharge Hours") missing.append("EV Discharge Hours")
msg = ", ".join(missing) msg = ", ".join(missing)
@@ -235,6 +254,7 @@ class GeneticSimulation(PydanticBaseModel):
revenue_per_hour = np.full((total_hours), np.nan) revenue_per_hour = np.full((total_hours), np.nan)
losses_wh_per_hour = np.full((total_hours), np.nan) losses_wh_per_hour = np.full((total_hours), np.nan)
electricity_price_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 # Set initial state
if battery_fast: if battery_fast:
@@ -272,7 +292,18 @@ class GeneticSimulation(PydanticBaseModel):
# Fill the discharge array of the battery # Fill the discharge array of the battery
bat_discharge_hours_fast[0:start_hour] = 0 bat_discharge_hours_fast[0:start_hour] = 0
bat_discharge_hours_fast[end_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: else:
# Default return if no battery is available # Default return if no battery is available
soc_per_hour = np.full((total_hours), 0) soc_per_hour = np.full((total_hours), 0)
@@ -348,12 +379,25 @@ class GeneticSimulation(PydanticBaseModel):
if inverter_fast: if inverter_fast:
energy_produced = pv_prediction_wh_fast[hour] 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_feedin_grid_actual,
energy_consumption_grid_actual, energy_consumption_grid_actual,
losses, losses,
eigenverbrauch, 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 # AC PV Battery Charge
if battery_fast: if battery_fast:
@@ -391,17 +435,26 @@ class GeneticSimulation(PydanticBaseModel):
) )
# Update hourly arrays # 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 feedin_energy_per_hour[hour_idx] = energy_feedin_grid_actual
consumption_energy_per_hour[hour_idx] = energy_consumption_grid_actual consumption_energy_per_hour[hour_idx] = energy_consumption_grid_actual
losses_wh_per_hour[hour_idx] += losses losses_wh_per_hour[hour_idx] += losses
loads_energy_per_hour[hour_idx] = consumption loads_energy_per_hour[hour_idx] = consumption
hourly_electricity_price = elect_price_hourly_fast[hour] hourly_electricity_price = elect_price_hourly_fast[hour]
electricity_price_per_hour[hour_idx] = hourly_electricity_price electricity_price_per_hour[hour_idx] = hourly_electricity_price
feed_in_tariff_per_hour[hour_idx] = hourly_feed_in_tariff
# Financial calculations # Financial calculations
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
revenue_per_hour[hour_idx] = ( 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) total_cost = np.nansum(costs_per_hour)
@@ -424,6 +477,7 @@ class GeneticSimulation(PydanticBaseModel):
"Gesamt_Verluste": total_losses, "Gesamt_Verluste": total_losses,
"Home_appliance_wh_per_hour": home_appliance_wh_per_hour, "Home_appliance_wh_per_hour": home_appliance_wh_per_hour,
"Electricity_price": electricity_price_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.fix_seed = fixed_seed
self.optimize_ev = True self.optimize_ev = True
self.optimize_dc_charge = False self.optimize_dc_charge = False
self.optimize_battery_grid_export = False
self.fitness_history: dict[str, Any] = {} self.fitness_history: dict[str, Any] = {}
# Set a fixed seed for random operations if provided or in debug mode # Set a fixed seed for random operations if provided or in debug mode
@@ -460,10 +515,42 @@ class GeneticOptimization(OptimizationBase):
# Create Simulation # Create Simulation
self.simulation = GeneticSimulation() 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( def decode_charge_discharge(
self, discharge_hours_bin: np.ndarray self, discharge_hours_bin: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Decode the input array into ac_charge, dc_charge, and discharge arrays.""" """Decode the input array into charge, self-consumption discharge and export arrays."""
discharge_hours_bin_np = np.array(discharge_hours_bin) discharge_hours_bin_np = np.array(discharge_hours_bin)
# Battery AC charge uses its own charge-level list (bat_possible_charge_values). # Battery AC charge uses its own charge-level list (bat_possible_charge_values).
len_bat = len(self.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 # Discharge: len_bat .. 2*len_bat - 1
# AC Charge: 2*len_bat .. 3*len_bat - 1 (maps to bat_possible_charge_values) # 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) # 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 states
idle_mask = (discharge_hours_bin_np >= 0) & (discharge_hours_bin_np < len_bat) 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 = np.zeros_like(discharge_hours_bin_np, dtype=float)
ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices] 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. # 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]]: def mutate(self, individual: list[int]) -> tuple[list[int]]:
"""Custom mutation function for the individual.""" """Custom mutation function for the individual."""
@@ -513,6 +606,8 @@ class GeneticOptimization(OptimizationBase):
total_states = 3 * len_bat + 2 total_states = 3 * len_bat + 2
else: else:
total_states = 3 * len_bat total_states = 3 * len_bat
if self.optimize_battery_grid_export:
total_states += 1
# 1. Mutating the charge_discharge part # 1. Mutating the charge_discharge part
charge_discharge_part = individual[: self.config.prediction.hours] charge_discharge_part = individual[: self.config.prediction.hours]
@@ -652,10 +747,13 @@ class GeneticOptimization(OptimizationBase):
# Discharge: len_bat states # Discharge: len_bat states
# AC-Charge: len_bat states (maps to bat_possible_charge_values) # AC-Charge: len_bat states (maps to bat_possible_charge_values)
# With DC: + 2 additional states # With DC: + 2 additional states
# With battery grid export: + 1 additional state
if self.optimize_dc_charge: if self.optimize_dc_charge:
total_states = 3 * len_bat + 2 total_states = 3 * len_bat + 2
else: else:
total_states = 3 * len_bat total_states = 3 * len_bat
if self.optimize_battery_grid_export:
total_states += 1
# State space: 0 .. (total_states - 1) # State space: 0 .. (total_states - 1)
self.toolbox.register("attr_discharge_state", random.randint, 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 # Set start hour for appliance
self.simulation.home_appliance_start_hour = washingstart_int self.simulation.home_appliance_start_hour = washingstart_int
ac_charge_hours, dc_charge_hours, discharge = self.decode_charge_discharge( ac_charge_hours, dc_charge_hours, discharge, battery_grid_export = (
discharge_hours_bin self.decode_charge_discharge(discharge_hours_bin)
) )
self.simulation.bat_discharge_hours = discharge 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 # Set DC charge hours only if DC optimization is enabled
if self.optimize_dc_charge: if self.optimize_dc_charge:
self.simulation.dc_charge_hours = dc_charge_hours self.simulation.dc_charge_hours = dc_charge_hours
@@ -1077,6 +1176,11 @@ class GeneticOptimization(OptimizationBase):
ngen: Optional[int] = None, ngen: Optional[int] = None,
) -> GeneticSolution: ) -> GeneticSolution:
"""Perform EMS (Energy Management System) optimization and visualize results.""" """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: if start_hour is None:
start_hour = self.ems.start_datetime.hour start_hour = self.ems.start_datetime.hour
# Start hour has to be in sync with energy management # Start hour has to be in sync with energy management
@@ -1094,10 +1198,6 @@ class GeneticOptimization(OptimizationBase):
generations = 400 generations = 400
logger.error("Generations not configured. Using {}.", generations) 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() self.simulation.reset()
# Initialize PV and EV batteries # Initialize PV and EV batteries
@@ -1195,6 +1295,7 @@ class GeneticOptimization(OptimizationBase):
inverter=inverter, # battery is part of inverter inverter=inverter, # battery is part of inverter
ev=eauto, ev=eauto,
home_appliance=dishwasher, home_appliance=dishwasher,
direct_marketing_enabled=direct_marketing_enabled,
) )
# Setup the DEAP environment and optimization process # Setup the DEAP environment and optimization process
@@ -1240,6 +1341,11 @@ class GeneticOptimization(OptimizationBase):
discharge = [] discharge = []
else: else:
discharge = discharge.tolist() 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 # Visualize the results in PDF
try: try:
@@ -1249,6 +1355,7 @@ class GeneticOptimization(OptimizationBase):
"ac_charge": ac_charge_hours, "ac_charge": ac_charge_hours,
"dc_charge": dc_charge_hours, "dc_charge": dc_charge_hours,
"discharge_allowed": discharge, "discharge_allowed": discharge,
"battery_grid_export_allowed": battery_grid_export,
"eautocharge_hours_float": eautocharge_hours_float, "eautocharge_hours_float": eautocharge_hours_float,
"result": simulation_result, "result": simulation_result,
"eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None, "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, "ac_charge": ac_charge_hours,
"dc_charge": dc_charge_hours, "dc_charge": dc_charge_hours,
"discharge_allowed": discharge, "discharge_allowed": discharge,
"battery_grid_export_allowed": battery_grid_export,
"eautocharge_hours_float": eautocharge_hours_float, "eautocharge_hours_float": eautocharge_hours_float,
"result": GeneticSimulationResult(**simulation_result), "result": GeneticSimulationResult(**simulation_result),
"eauto_obj": self.simulation.ev, "eauto_obj": self.simulation.ev,

View File

@@ -330,6 +330,21 @@ class GeneticOptimizationParameters(
) )
# Retry # Retry
continue continue
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: try:
feed_in_tariff_wh = cls.prediction.key_to_array( feed_in_tariff_wh = cls.prediction.key_to_array(
key="feed_in_tariff_wh", key="feed_in_tariff_wh",

View File

@@ -130,6 +130,12 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
Electricity_price: list[float] = Field( Electricity_price: list[float] = Field(
json_schema_extra={"description": "Used Electricity Price, including predictions"} 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( @field_validator(
"Last_Wh_pro_Stunde", "Last_Wh_pro_Stunde",
@@ -142,6 +148,7 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
"Verluste_Pro_Stunde", "Verluste_Pro_Stunde",
"Home_appliance_wh_per_hour", "Home_appliance_wh_per_hour",
"Electricity_price", "Electricity_price",
"Feed_in_tariff",
mode="before", mode="before",
) )
def convert_numpy(cls, field: Any) -> Any: def convert_numpy(cls, field: Any) -> Any:
@@ -163,7 +170,13 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
) )
discharge_allowed: list[int] = Field( discharge_allowed: list[int] = Field(
json_schema_extra={ 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"}) eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
@@ -186,6 +199,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"ac_charge", "ac_charge",
"dc_charge", "dc_charge",
"discharge_allowed", "discharge_allowed",
"battery_grid_export_allowed",
mode="before", mode="before",
) )
def convert_numpy(cls, field: Any) -> Any: def convert_numpy(cls, field: Any) -> Any:
@@ -226,13 +240,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
ac_charge: float, ac_charge: float,
dc_charge: float, dc_charge: float,
discharge_allowed: bool, discharge_allowed: bool,
battery_grid_export_allowed: bool = False,
) -> tuple[BatteryOperationMode, float]: ) -> tuple[BatteryOperationMode, float]:
"""Maps low-level solution to a representative operation mode and factor. """Maps low-level solution to a representative operation mode and factor.
Args: Args:
ac_charge (float): Allowed AC-side charging power (relative units). ac_charge (float): Allowed AC-side charging power (relative units).
dc_charge (float): Allowed DC-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: Returns:
tuple[BatteryOperationMode, float]: A tuple containing tuple[BatteryOperationMode, float]: A tuple containing
@@ -240,15 +256,28 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- `float`: the operation factor corresponding to the active signal. - `float`: the operation factor corresponding to the active signal.
Notes: 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 - Multiple strategies can produce the same low-level signals; this function
returns a representative mode based on a defined priority order. returns a representative mode based on a defined priority order.
""" """
# (0,0,0) → Nothing allowed # (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 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: if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
return BatteryOperationMode.PEAK_SHAVING, 1.0 return BatteryOperationMode.PEAK_SHAVING, 1.0
@@ -289,7 +318,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
dc_charge: float, dc_charge: float,
discharge_allowed: bool, discharge_allowed: bool,
soc_pct: float, 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. """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 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 - DC charge factor (PV): zeroed when battery is at or above max SOC
(the inverter curtails automatically, but this makes intent clear). (the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC. - 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 bat_list = self.config.devices.batteries
if not bat_list: 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] bat = bat_list[0]
min_soc = float(bat.min_soc_percentage) min_soc = float(bat.min_soc_percentage)
@@ -341,19 +372,22 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
# --- Discharge: block at min SOC --- # --- Discharge: block at min SOC ---
effective_dis = discharge_allowed and (soc_pct > 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: def optimization_solution(self) -> OptimizationSolution:
"""Provide the genetic solution as a general optimization solution. """Provide the genetic solution as a general optimization solution.
The battery modes are controlled by the grid control triggers: The battery modes are controlled by the grid control triggers:
- ac_charge: charge from grid - 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: The following battery modes are supported:
- SELF_CONSUMPTION: ac_charge == 0 and discharge_allowed == 0 - SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1
- GRID_SUPPORT_EXPORT: ac_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 - GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
""" """
start_datetime = get_ems().start_datetime start_datetime = get_ems().start_datetime
@@ -407,6 +441,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"genetic_ac_charge_factor": [], "genetic_ac_charge_factor": [],
"genetic_dc_charge_factor": [], "genetic_dc_charge_factor": [],
"genetic_discharge_allowed_factor": [], "genetic_discharge_allowed_factor": [],
"genetic_battery_grid_export_allowed_factor": [],
} }
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day # ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
for hour_idx, rate in enumerate(self.ac_charge): 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] ac_charge_hour = self.ac_charge[hour_idx]
dc_charge_hour = self.dc_charge[hour_idx] dc_charge_hour = self.dc_charge[hour_idx]
discharge_allowed_hour = bool(self.discharge_allowed[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 # Raw genetic gene values — optimizer intent, stored verbatim
operation["genetic_ac_charge_factor"].append(ac_charge_hour) operation["genetic_ac_charge_factor"].append(ac_charge_hour)
operation["genetic_dc_charge_factor"].append(dc_charge_hour) operation["genetic_dc_charge_factor"].append(dc_charge_hour)
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_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 # SOC-clamped effective values — what can physically be executed at
# this hour given the expected battery state of charge. # 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) if result_idx < len(self.result.akku_soc_pro_stunde)
else 0.0 else 0.0
) )
eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors( 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 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( 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: for mode in BatteryOperationMode:
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode" 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) if result_idx < len(self.result.akku_soc_pro_stunde)
else 0.0 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.ac_charge[hour_idx],
self.dc_charge[hour_idx], self.dc_charge[hour_idx],
bool(self.discharge_allowed[hour_idx]), bool(self.discharge_allowed[hour_idx]),
soc_h_pct, soc_h_pct,
battery_grid_export_allowed_hour,
) )
operation_mode, operation_mode_factor = self._battery_operation_from_solution( 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 ( if (
operation_mode == last_operation_mode 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.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.prediction.feedintariffenergycharts import (
FeedInTariffEnergyChartsCommonSettings,
)
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
@@ -16,7 +19,7 @@ def elecprice_provider_ids() -> list[str]:
except: except:
# Prediction may not be initialized # Prediction may not be initialized
# Return at least provider used in example # Return at least provider used in example
return ["FeedInTariffFixed", "FeedInTarifImport"] return ["FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport"]
return [ return [
provider.provider_id() provider.provider_id()
@@ -32,6 +35,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
default=None, default=None,
json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [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( FeedInTariffImport: Optional[FeedInTariffImportCommonSettings] = Field(
default=None, default=None,
json_schema_extra={"description": "FeedInTariffImport settings", "examples": [None]}, json_schema_extra={"description": "FeedInTariffImport settings", "examples": [None]},
@@ -41,11 +48,23 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
class FeedInTariffCommonSettings(SettingsBaseModel): class FeedInTariffCommonSettings(SettingsBaseModel):
"""Feed In Tariff Prediction Configuration.""" """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( provider: Optional[str] = Field(
default=None, default=None,
json_schema_extra={ json_schema_extra={
"description": "Feed in tariff provider id of provider to be used.", "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) # Example 1: Empty/default settings (all providers None)
{ {
"FeedInTariffFixed": None, "FeedInTariffFixed": None,
"FeedInTariffEnergyCharts": None,
"FeedInTariffImport": 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.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import ( from akkudoktoreos.prediction.loadakkudoktor import (
@@ -81,6 +82,8 @@ elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_tibber = ElecPriceTibber() elecprice_tibber = ElecPriceTibber()
elecprice_fixed = ElecPriceFixed() elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport() elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed() feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport() feedintariff_import = FeedInTariffImport()
loadforecast_akkudoktor = LoadAkkudoktor() loadforecast_akkudoktor = LoadAkkudoktor()
@@ -106,6 +109,8 @@ def prediction_providers() -> list[
ElecPriceTibber, ElecPriceTibber,
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
LoadAkkudoktor, LoadAkkudoktor,
@@ -134,6 +139,8 @@ def prediction_providers() -> list[
elecprice_tibber, \ elecprice_tibber, \
elecprice_fixed, \ elecprice_fixed, \
elecprice_import, \ elecprice_import, \
elecprice_tibber, \
feedintariff_energy_charts, \
feedintariff_fixed, \ feedintariff_fixed, \
feedintariff_import, \ feedintariff_import, \
loadforecast_akkudoktor, \ loadforecast_akkudoktor, \
@@ -158,6 +165,8 @@ def prediction_providers() -> list[
elecprice_tibber, elecprice_tibber,
elecprice_fixed, elecprice_fixed,
elecprice_import, elecprice_import,
elecprice_tibber,
feedintariff_energy_charts,
feedintariff_fixed, feedintariff_fixed,
feedintariff_import, feedintariff_import,
loadforecast_akkudoktor, loadforecast_akkudoktor,
@@ -187,6 +196,8 @@ class Prediction(PredictionContainer):
ElecPriceTibber, ElecPriceTibber,
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
LoadAkkudoktor, LoadAkkudoktor,

View File

@@ -549,17 +549,29 @@ def prepare_visualize(
) )
labels = labels[start_hour:] + labels labels = labels[start_hour:] + labels
report.create_bar_chart( charge_discharge_series = [
labels,
[
results["ac_charge"][start_hour:], results["ac_charge"][start_hour:],
results["dc_charge"][start_hour:], results["dc_charge"][start_hour:],
results["discharge_allowed"][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,
charge_discharge_series,
title="AC/DC Charging and Discharge Overview", title="AC/DC Charging and Discharge Overview",
ylabel="Relative Power (0-1) / Discharge (0 or 1)", ylabel="Relative Power (0-1) / Discharge (0 or 1)",
label_names=["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"], label_names=charge_discharge_labels,
colors=["blue", "green", "red"], colors=charge_discharge_colors,
bottom=3, bottom=3,
xlabels=labels, 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) 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 # 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) 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( @pytest.mark.parametrize(
"fn_in, fn_out, ngen, break_even", "fn_in, fn_out, ngen, break_even",
[ [

View File

@@ -1,3 +1,5 @@
from unittest.mock import Mock
import numpy as np import numpy as np
import pytest import pytest
@@ -249,6 +251,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
assert simulation.ac_charge_hours is not None assert simulation.ac_charge_hours is not None
assert simulation.dc_charge_hours is not None assert simulation.dc_charge_hours is not None
assert simulation.bat_discharge_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 assert simulation.ev_charge_hours is not None
simulation.ac_charge_hours[start_hour] = 1.0 simulation.ac_charge_hours[start_hour] = 1.0
simulation.dc_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." ), "The sum of 'Home_appliance_wh_per_hour' should be 2000."
print("All tests passed successfully.") 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 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() 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): def test_process_energy_battery_empty(inverter, mock_battery):
# Battery is empty, so no energy can be discharged # Battery is empty, so no energy can be discharged
mock_battery.discharge_energy.return_value = (0.0, 0.0) 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"