mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-16 11:01:56 +00:00
feat(devices): port slot-aware battery export and direct-use physics
Port scoped device changes from d2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results.
Co-authored-by: Andreas <drbacke@gmx.de>
Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
+177
-28
@@ -1,8 +1,13 @@
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.devices.genetic.battery import (
|
||||
SolarPanelBatteryParameters,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -10,6 +15,9 @@ def mock_battery() -> Mock:
|
||||
mock_battery = Mock()
|
||||
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
|
||||
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
|
||||
# Rated discharge energy of one slot - the reference a grid-export rate is
|
||||
# applied to. Large enough to never bind at the default factor of 1.0.
|
||||
mock_battery.rated_discharge_energy_wh = Mock(return_value=1e9)
|
||||
mock_battery.parameters.device_id = "battery1"
|
||||
return mock_battery
|
||||
|
||||
@@ -17,7 +25,7 @@ def mock_battery() -> Mock:
|
||||
@pytest.fixture
|
||||
def inverter(mock_battery) -> Inverter:
|
||||
mock_self_consumption_predictor = Mock()
|
||||
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
|
||||
mock_self_consumption_predictor.calculate_expected_direct_consumption.side_effect = min
|
||||
with patch(
|
||||
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
|
||||
return_value=mock_self_consumption_predictor,
|
||||
@@ -26,11 +34,51 @@ def inverter(mock_battery) -> Inverter:
|
||||
InverterParameters(
|
||||
device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.parameters.device_id
|
||||
),
|
||||
battery = mock_battery
|
||||
battery=mock_battery,
|
||||
)
|
||||
return iv
|
||||
|
||||
|
||||
def test_quarter_hour_load_and_grid_export_share_discharge_power_limit():
|
||||
"""Local supply plus direct export may not exceed one slot's battery budget."""
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery",
|
||||
capacity_wh=10000,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
max_charge_power_w=7000,
|
||||
initial_soc_percentage=100,
|
||||
),
|
||||
prediction_hours=1,
|
||||
slot_duration_h=0.25,
|
||||
)
|
||||
battery.set_discharge_per_hour(np.array([1]))
|
||||
quarter_hour_inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter",
|
||||
max_power_wh=10000,
|
||||
battery_id="battery",
|
||||
dc_to_ac_efficiency=1.0,
|
||||
ac_to_dc_efficiency=1.0,
|
||||
),
|
||||
battery=battery,
|
||||
slot_duration_h=0.25,
|
||||
)
|
||||
initial_soc_wh = battery.soc_wh
|
||||
|
||||
grid_export, grid_import, _, _ = quarter_hour_inverter.process_energy(
|
||||
generation=0.0,
|
||||
consumption=1000.0,
|
||||
hour=0,
|
||||
allow_battery_grid_export=True,
|
||||
)
|
||||
|
||||
assert grid_import == 0.0
|
||||
assert grid_export == pytest.approx(750.0)
|
||||
assert initial_soc_wh - battery.soc_wh == pytest.approx(1750.0)
|
||||
|
||||
|
||||
def test_process_energy_excess_generation(inverter, mock_battery):
|
||||
# Battery charges 100 Wh with 10 Wh loss
|
||||
mock_battery.charge_energy.return_value = (100.0, 10.0)
|
||||
@@ -48,7 +96,7 @@ def test_process_energy_excess_generation(inverter, mock_battery):
|
||||
assert self_consumption == 200.0 # All consumption is met
|
||||
mock_battery.charge_energy.assert_called_once_with(400.0, hour)
|
||||
mock_battery.discharge_energy.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
@@ -57,7 +105,8 @@ def test_process_energy_excess_generation_interpolator(inverter, mock_battery):
|
||||
# Battery charges 100 Wh with 10 Wh loss
|
||||
mock_battery.charge_energy.return_value = (100.0, 10.0)
|
||||
mock_battery.discharge_energy.return_value = (20.0, 2.0)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.return_value = 0.95
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.side_effect = None
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.return_value = 180.0
|
||||
|
||||
generation = 600.0
|
||||
consumption = 200.0
|
||||
@@ -67,19 +116,71 @@ def test_process_energy_excess_generation_interpolator(inverter, mock_battery):
|
||||
generation, consumption, hour
|
||||
)
|
||||
|
||||
assert grid_export == pytest.approx(
|
||||
270.0, rel=1e-2
|
||||
) # 290 Wh feed-in - 5% of generation-consumption self consumption after battery charges
|
||||
assert grid_export == pytest.approx(300.0, rel=1e-2)
|
||||
assert grid_import == pytest.approx(0.0, rel=1e-2) # No grid draw
|
||||
assert losses == 12.0 # Battery charging losses
|
||||
assert self_consumption == 220.0 # All consumption is met
|
||||
mock_battery.charge_energy.assert_called_once_with(pytest.approx(380.0, rel=1e-2), hour)
|
||||
assert losses == 22.0 # Battery/inverter losses plus curtailed PV
|
||||
assert self_consumption == 200.0 # 180 Wh direct PV + 20 Wh battery
|
||||
mock_battery.charge_energy.assert_called_once_with(pytest.approx(420.0, rel=1e-2), hour)
|
||||
mock_battery.discharge_energy.assert_called_once_with(pytest.approx(20.0, rel=1e-2), hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_probabilistic_bypass_conserves_energy_without_battery():
|
||||
predictor = Mock()
|
||||
predictor.calculate_expected_direct_consumption.return_value = 150.0
|
||||
with patch(
|
||||
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
|
||||
return_value=predictor,
|
||||
):
|
||||
inverter_without_battery = Inverter(
|
||||
InverterParameters(device_id="inverter", max_power_wh=1000.0)
|
||||
)
|
||||
|
||||
generation = 600.0
|
||||
consumption = 200.0
|
||||
grid_export, grid_import, losses, self_consumption = (
|
||||
inverter_without_battery.process_energy(generation, consumption, hour=0)
|
||||
)
|
||||
|
||||
assert self_consumption == pytest.approx(150.0)
|
||||
assert grid_import == pytest.approx(50.0)
|
||||
assert grid_export == pytest.approx(450.0)
|
||||
assert losses == 0.0
|
||||
assert generation + grid_import == pytest.approx(
|
||||
consumption + grid_export + losses
|
||||
)
|
||||
|
||||
|
||||
def test_probabilistic_bypass_conserves_energy_on_quarter_hour_grid():
|
||||
predictor = Mock()
|
||||
predictor.calculate_expected_direct_consumption.return_value = 600.0
|
||||
with patch(
|
||||
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
|
||||
return_value=predictor,
|
||||
):
|
||||
inverter_without_battery = Inverter(
|
||||
InverterParameters(device_id="inverter", max_power_wh=2000.0),
|
||||
slot_duration_h=0.25,
|
||||
)
|
||||
|
||||
generation = 300.0 # 1200 W over 15 minutes
|
||||
consumption = 200.0 # 800 W over 15 minutes
|
||||
grid_export, grid_import, losses, self_consumption = (
|
||||
inverter_without_battery.process_energy(generation, consumption, hour=0)
|
||||
)
|
||||
|
||||
predictor.calculate_expected_direct_consumption.assert_called_once_with(800.0, 1200.0)
|
||||
assert self_consumption == pytest.approx(150.0)
|
||||
assert grid_import == pytest.approx(50.0)
|
||||
assert grid_export == pytest.approx(150.0)
|
||||
assert losses == 0.0
|
||||
assert generation + grid_import == pytest.approx(
|
||||
consumption + grid_export + losses
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_generation_equals_consumption(inverter, mock_battery):
|
||||
generation = 300.0
|
||||
consumption = 300.0
|
||||
@@ -96,7 +197,7 @@ def test_process_energy_generation_equals_consumption(inverter, mock_battery):
|
||||
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
@@ -120,7 +221,49 @@ def test_process_energy_battery_discharges(inverter, mock_battery):
|
||||
assert self_consumption == 200.0 # Generation + battery discharge
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(150.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_allows_battery_grid_export(inverter, mock_battery):
|
||||
mock_battery.max_charge_power_w = 300.0
|
||||
mock_battery.remaining_discharge_energy_wh.return_value = 200.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_expected_direct_consumption.assert_not_called()
|
||||
|
||||
|
||||
def test_process_energy_grid_export_rate_limits_export(inverter, mock_battery):
|
||||
"""An export rate caps the export at that share of the rated discharge power."""
|
||||
mock_battery.max_charge_power_w = 300.0
|
||||
mock_battery.remaining_discharge_energy_wh.return_value = 200.0
|
||||
mock_battery.rated_discharge_energy_wh.return_value = 300.0
|
||||
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (150.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,
|
||||
battery_grid_export_factor=0.5,
|
||||
)
|
||||
|
||||
# 0.5 * 300 Wh rated = 150 Wh, below the 200 Wh the battery could still give.
|
||||
assert grid_export == pytest.approx(150.0)
|
||||
mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(150.0, 12)])
|
||||
|
||||
|
||||
def test_process_energy_battery_empty(inverter, mock_battery):
|
||||
@@ -140,7 +283,9 @@ def test_process_energy_battery_empty(inverter, mock_battery):
|
||||
assert self_consumption == 100.0 # Only generation is consumed
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(200.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_battery_full_at_start(inverter, mock_battery):
|
||||
@@ -162,7 +307,7 @@ def test_process_energy_battery_full_at_start(inverter, mock_battery):
|
||||
assert self_consumption == 200.0 # Only consumption is met
|
||||
mock_battery.charge_energy.assert_called_once_with(300.0, hour)
|
||||
mock_battery.discharge_energy.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
@@ -184,7 +329,9 @@ def test_process_energy_insufficient_generation_no_battery(inverter, mock_batter
|
||||
assert self_consumption == 100.0 # Only generation is consumed
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(400.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_insufficient_generation_battery_assists(inverter, mock_battery):
|
||||
@@ -209,7 +356,9 @@ def test_process_energy_insufficient_generation_battery_assists(inverter, mock_b
|
||||
assert self_consumption == 250.0 # Generation + battery discharge
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(200.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_zero_generation(inverter, mock_battery):
|
||||
@@ -232,7 +381,7 @@ def test_process_energy_zero_generation(inverter, mock_battery):
|
||||
assert self_consumption == 100.0 # Only battery discharge is consumed
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(300.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
|
||||
|
||||
|
||||
def test_process_energy_zero_consumption(inverter, mock_battery):
|
||||
@@ -252,9 +401,7 @@ def test_process_energy_zero_consumption(inverter, mock_battery):
|
||||
assert self_consumption == 0.0 # Zero consumption
|
||||
mock_battery.charge_energy.assert_called_once_with(500.0, hour)
|
||||
mock_battery.discharge_energy.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
|
||||
|
||||
|
||||
def test_process_energy_zero_generation_zero_consumption(inverter, mock_battery):
|
||||
@@ -272,9 +419,7 @@ def test_process_energy_zero_generation_zero_consumption(inverter, mock_battery)
|
||||
assert self_consumption == 0.0 # No consumption
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
|
||||
|
||||
|
||||
def test_process_energy_partial_battery_discharge(inverter, mock_battery):
|
||||
@@ -295,7 +440,9 @@ def test_process_energy_partial_battery_discharge(inverter, mock_battery):
|
||||
assert self_consumption == 250.0 # Generation + battery discharge
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(200.0, 12)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_consumption_exceeds_max_no_battery(inverter, mock_battery):
|
||||
@@ -315,7 +462,9 @@ def test_process_energy_consumption_exceeds_max_no_battery(inverter, mock_batter
|
||||
assert self_consumption == 100.0 # Only the generation is consumed, maxing out the inverter
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(400.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
|
||||
consumption, generation
|
||||
)
|
||||
|
||||
|
||||
def test_process_energy_zero_generation_full_battery_high_consumption(inverter, mock_battery):
|
||||
@@ -337,4 +486,4 @@ def test_process_energy_zero_generation_full_battery_high_consumption(inverter,
|
||||
assert self_consumption == 500.0 # Battery fully discharges to meet consumption
|
||||
mock_battery.charge_energy.assert_not_called()
|
||||
mock_battery.discharge_energy.assert_called_once_with(500.0, hour)
|
||||
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
|
||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user