feat: Direktvermarktung mit Batterie-Netzeinspeisung

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas
2026-07-12 09:01:11 +02:00
parent cc583600d8
commit 7f2ac9098c
19 changed files with 960 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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