mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-13 05:18:12 +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:
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)
|
||||
|
||||
|
||||
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
|
||||
# ------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
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