mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-12 21:08:13 +00:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
69
openapi.json
69
openapi.json
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -330,33 +330,48 @@ class GeneticOptimizationParameters(
|
|||||||
)
|
)
|
||||||
# Retry
|
# Retry
|
||||||
continue
|
continue
|
||||||
try:
|
if cls.config.feedintariff.direct_marketing_enabled:
|
||||||
feed_in_tariff_wh = cls.prediction.key_to_array(
|
if cls.config.feedintariff.provider == "FeedInTariffEnergyCharts":
|
||||||
key="feed_in_tariff_wh",
|
try:
|
||||||
start_datetime=parameter_start_datetime,
|
feed_in_tariff_wh = cls.prediction.key_to_array(
|
||||||
end_datetime=parameter_end_datetime,
|
key="feed_in_tariff_wh",
|
||||||
interval=interval,
|
start_datetime=parameter_start_datetime,
|
||||||
fill_method="ffill",
|
end_datetime=parameter_end_datetime,
|
||||||
).tolist()
|
interval=interval,
|
||||||
except:
|
fill_method="ffill",
|
||||||
logger.info(
|
).tolist()
|
||||||
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}.",
|
except:
|
||||||
attempt,
|
feed_in_tariff_wh = list(elecprice_marketprice_wh)
|
||||||
)
|
else:
|
||||||
cls.config.merge_settings_from_dict(
|
feed_in_tariff_wh = list(elecprice_marketprice_wh)
|
||||||
{
|
else:
|
||||||
"feedintariff": {
|
try:
|
||||||
"provider": "FeedInTariffFixed",
|
feed_in_tariff_wh = cls.prediction.key_to_array(
|
||||||
"provider_settings": {
|
key="feed_in_tariff_wh",
|
||||||
"FeedInTariffFixed": {
|
start_datetime=parameter_start_datetime,
|
||||||
"feed_in_tariff_kwh": 0.078,
|
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
|
||||||
# Retry
|
continue
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
weather_temp_air = cls.prediction.key_to_array(
|
weather_temp_air = cls.prediction.key_to_array(
|
||||||
key="weather_temp_air",
|
key="weather_temp_air",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
179
src/akkudoktoreos/prediction/feedintariffenergycharts.py
Normal file
179
src/akkudoktoreos/prediction/feedintariffenergycharts.py
Normal 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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -549,17 +549,29 @@ def prepare_visualize(
|
|||||||
)
|
)
|
||||||
labels = labels[start_hour:] + labels
|
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(
|
report.create_bar_chart(
|
||||||
labels,
|
labels,
|
||||||
[
|
charge_discharge_series,
|
||||||
results["ac_charge"][start_hour:],
|
|
||||||
results["dc_charge"][start_hour:],
|
|
||||||
results["discharge_allowed"][start_hour:],
|
|
||||||
],
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
77
tests/test_feedintariffenergycharts.py
Normal file
77
tests/test_feedintariffenergycharts.py
Normal 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
|
||||||
@@ -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
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
56
tests/test_geneticsolution.py
Normal file
56
tests/test_geneticsolution.py
Normal 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]
|
||||||
@@ -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)
|
||||||
|
|||||||
36
tests/test_strompreis_endpoint.py
Normal file
36
tests/test_strompreis_endpoint.py
Normal 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"
|
||||||
Reference in New Issue
Block a user