Files
EOS/tests/test_inverter.py
T

467 lines
18 KiB
Python
Raw Normal View History

from unittest.mock import Mock, call, patch
2024-12-16 15:33:00 +01:00
import numpy as np
2024-12-16 15:33:00 +01:00
import pytest
from akkudoktoreos.devices.genetic.battery import Battery
2025-10-28 02:50:31 +01:00
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.optimization.genetic.geneticdevices import (
SolarPanelBatteryParameters,
)
2024-12-16 15:33:00 +01:00
@pytest.fixture
2025-01-12 05:19:37 +01:00
def mock_battery() -> Mock:
2024-12-16 15:33:00 +01:00
mock_battery = Mock()
2024-12-19 14:50:19 +01:00
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
2025-10-28 02:50:31 +01:00
mock_battery.parameters.device_id = "battery1"
2024-12-16 15:33:00 +01:00
return mock_battery
@pytest.fixture
2025-10-28 02:50:31 +01:00
def inverter(mock_battery) -> Inverter:
mock_self_consumption_predictor = Mock()
mock_self_consumption_predictor.calculate_expected_direct_consumption.side_effect = min
2025-01-12 05:19:37 +01:00
with patch(
2025-10-28 02:50:31 +01:00
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
2025-01-12 05:19:37 +01:00
return_value=mock_self_consumption_predictor,
):
iv = Inverter(
2025-01-18 14:26:34 +01:00
InverterParameters(
2025-10-28 02:50:31 +01:00
device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.parameters.device_id
2025-01-18 14:26:34 +01:00
),
battery=mock_battery,
2025-01-12 05:19:37 +01:00
)
return iv
2024-12-16 15:33:00 +01:00
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)
2024-12-16 15:33:00 +01:00
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)
2024-12-16 15:33:00 +01:00
generation = 600.0
consumption = 200.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == pytest.approx(290.0, rel=1e-2) # 290 Wh feed-in after battery charges
assert grid_import == 0.0 # No grid draw
assert losses == 10.0 # Battery charging losses
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_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_excess_generation_interpolator(inverter, mock_battery):
# Battery charges 100 Wh with 10 Wh loss
2024-12-19 14:45:20 +01:00
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_expected_direct_consumption.side_effect = None
inverter.self_consumption_predictor.calculate_expected_direct_consumption.return_value = 180.0
generation = 600.0
consumption = 200.0
hour = 12
2024-12-19 14:45:20 +01:00
2024-12-16 15:33:00 +01:00
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
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 == 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_expected_direct_consumption.assert_called_once_with(
consumption, generation
2024-12-19 14:45:20 +01:00
)
2024-12-16 15:33:00 +01:00
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
)
2024-12-16 15:33:00 +01:00
def test_process_energy_generation_equals_consumption(inverter, mock_battery):
generation = 300.0
consumption = 300.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in as generation equals consumption
assert grid_import == 0.0 # No grid draw
assert losses == 0.0 # No losses
assert self_consumption == 300.0 # All consumption is met with generation
2024-12-19 14:45:20 +01:00
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
def test_process_energy_battery_discharges(inverter, mock_battery):
# Battery discharges 100 Wh with 10 Wh loss already accounted for in the discharge
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (100.0, 10.0)
2024-12-16 15:33:00 +01:00
generation = 100.0
consumption = 250.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in as generation is insufficient
assert grid_import == pytest.approx(
50.0, rel=1e-2
) # Grid supplies remaining shortfall after battery discharge
assert losses == 10.0 # Discharge losses
assert self_consumption == 200.0 # Generation + battery discharge
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(150.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
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()
2024-12-16 15:33:00 +01:00
def test_process_energy_battery_empty(inverter, mock_battery):
# Battery is empty, so no energy can be discharged
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (0.0, 0.0)
2024-12-16 15:33:00 +01:00
generation = 100.0
consumption = 300.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in as generation is insufficient
assert grid_import == pytest.approx(200.0, rel=1e-2) # Grid has to cover the full shortfall
assert losses == 0.0 # No losses as the battery didn't discharge
assert self_consumption == 100.0 # Only generation is consumed
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(200.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
def test_process_energy_battery_full_at_start(inverter, mock_battery):
# Battery is full, so no charging happens
2024-12-19 14:50:19 +01:00
mock_battery.charge_energy.return_value = (0.0, 0.0)
2024-12-16 15:33:00 +01:00
generation = 500.0
consumption = 200.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == pytest.approx(
300.0, rel=1e-2
2024-12-16 15:33:00 +01:00
) # All excess energy should be fed into the grid
assert grid_import == 0.0 # No grid draw
2024-12-16 15:33:00 +01:00
assert losses == 0.0 # No losses
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_expected_direct_consumption.assert_called_once_with(
consumption, generation
2024-12-19 14:45:20 +01:00
)
2024-12-16 15:33:00 +01:00
def test_process_energy_insufficient_generation_no_battery(inverter, mock_battery):
# Insufficient generation and no battery discharge
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (0.0, 0.0)
2024-12-16 15:33:00 +01:00
generation = 100.0
consumption = 500.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in as generation is insufficient
assert grid_import == pytest.approx(400.0, rel=1e-2) # Grid supplies the shortfall
assert losses == 0.0 # No losses
assert self_consumption == 100.0 # Only generation is consumed
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(400.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
def test_process_energy_insufficient_generation_battery_assists(inverter, mock_battery):
# Battery assists with some discharge to cover the shortfall
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (
2024-12-16 15:33:00 +01:00
50.0,
5.0,
) # Battery discharges 50 Wh with 5 Wh loss
generation = 200.0
consumption = 400.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in as generation is insufficient
assert grid_import == pytest.approx(
150.0, rel=1e-2
) # Grid supplies the remaining shortfall after battery discharge
assert losses == 5.0 # Discharge losses
assert self_consumption == 250.0 # Generation + battery discharge
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(200.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
def test_process_energy_zero_generation(inverter, mock_battery):
# Zero generation, full reliance on battery and grid
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (
2024-12-16 15:33:00 +01:00
100.0,
5.0,
) # Battery discharges 100 Wh with 5 Wh loss
generation = 0.0
consumption = 300.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in as there is zero generation
assert grid_import == pytest.approx(200.0, rel=1e-2) # Grid supplies the remaining shortfall
assert losses == 5.0 # Discharge losses
assert self_consumption == 100.0 # Only battery discharge is consumed
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(300.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
2024-12-16 15:33:00 +01:00
def test_process_energy_zero_consumption(inverter, mock_battery):
# Generation exceeds consumption, but consumption is zero
2024-12-19 14:50:19 +01:00
mock_battery.charge_energy.return_value = (100.0, 10.0)
2024-12-16 15:33:00 +01:00
generation = 500.0
consumption = 0.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == pytest.approx(390.0, rel=1e-2) # Excess energy after battery charges
assert grid_import == 0.0 # No grid draw as no consumption
2024-12-16 15:33:00 +01:00
assert losses == 10.0 # Charging losses
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_expected_direct_consumption.assert_not_called()
2024-12-16 15:33:00 +01:00
def test_process_energy_zero_generation_zero_consumption(inverter, mock_battery):
generation = 0.0
consumption = 0.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in
assert grid_import == 0.0 # No grid draw
assert losses == 0.0 # No losses
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_expected_direct_consumption.assert_not_called()
2024-12-16 15:33:00 +01:00
def test_process_energy_partial_battery_discharge(inverter, mock_battery):
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (50.0, 5.0)
2024-12-16 15:33:00 +01:00
generation = 200.0
consumption = 400.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in due to insufficient generation
assert grid_import == pytest.approx(
150.0, rel=1e-2
) # Grid supplies the shortfall after battery assist
assert losses == 5.0 # Discharge losses
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_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
def test_process_energy_consumption_exceeds_max_no_battery(inverter, mock_battery):
# Battery is empty, and consumption is much higher than the inverter's max power
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (0.0, 0.0)
2024-12-16 15:33:00 +01:00
generation = 100.0
consumption = 1000.0 # Exceeds the inverter's max power
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in
assert grid_import == pytest.approx(900.0, rel=1e-2) # Grid covers the remaining shortfall
assert losses == 0.0 # No losses as the battery didnt assist
assert self_consumption == 100.0 # Only the generation is consumed, maxing out the inverter
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(400.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
2024-12-16 15:33:00 +01:00
def test_process_energy_zero_generation_full_battery_high_consumption(inverter, mock_battery):
# Full battery, no generation, and high consumption
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.return_value = (500.0, 10.0)
2024-12-16 15:33:00 +01:00
generation = 0.0
consumption = 600.0
hour = 12
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation, consumption, hour
)
assert grid_export == 0.0 # No feed-in due to zero generation
assert grid_import == pytest.approx(
100.0, rel=1e-2
) # Grid covers remaining shortfall after battery discharge
assert losses == 10.0 # Battery discharge losses
assert self_consumption == 500.0 # Battery fully discharges to meet consumption
mock_battery.charge_energy.assert_not_called()
2024-12-19 14:50:19 +01:00
mock_battery.discharge_energy.assert_called_once_with(500.0, hour)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()