diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index b51f28f6..73382e69 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -366,6 +366,9 @@ class Battery: # Raw extractable energy above minimum SoC raw_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0) + if raw_available_wh == 0.0: + # A measured SoC below the reserve must not be raised by discharging. + return 0.0, 0.0 # Maximum raw discharge due to power limit, scaled to the slot duration. # max_charge_power_w is a power [W]; energy movable in one slot is diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index 48db3dc2..dadf9eeb 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -304,6 +304,9 @@ class Inverter: discharged_raw_wh = battery._discharged_raw_wh_per_slot discharging_efficiency = battery.discharging_efficiency raw_available_wh = max(soc_wh - min_soc_wh, 0.0) + if raw_available_wh == 0.0: + # Match Battery.discharge_energy(): preserve a SoC below the reserve. + return 0.0, 0.0 max_raw_wh = max( battery.max_charge_power_w * battery.slot_duration_h - discharged_raw_wh[hour], 0.0 ) diff --git a/src/akkudoktoreos/devices/genetic0/genetic0battery.py b/src/akkudoktoreos/devices/genetic0/genetic0battery.py index ec511c37..6faa75e1 100644 --- a/src/akkudoktoreos/devices/genetic0/genetic0battery.py +++ b/src/akkudoktoreos/devices/genetic0/genetic0battery.py @@ -227,6 +227,9 @@ class Genetic0Battery: # Raw extractable energy above minimum SoC raw_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0) + if raw_available_wh == 0.0: + # A measured SoC below the reserve must not be raised by discharging. + return 0.0, 0.0 # Maximum raw discharge due to power limit max_raw_wh = self.max_charge_power_w # TODO rename to max_discharge_power_w diff --git a/tests/test_genetic0battery.py b/tests/test_genetic0battery.py index 39c07159..42c49a7e 100644 --- a/tests/test_genetic0battery.py +++ b/tests/test_genetic0battery.py @@ -119,6 +119,20 @@ def test_battery_discharge_exactly_min_soc(setup_pv_battery): assert battery.current_soc_percentage() == 20 # SOC should remain at min_SOC +@pytest.mark.parametrize("initial_soc", [0, 5, 20]) +@pytest.mark.parametrize("requested_wh", [0.0, 280.0]) +def test_discharge_preserves_energy_at_or_below_min_soc( + setup_pv_battery, initial_soc, requested_wh +): + battery = setup_pv_battery + battery.soc_wh = initial_soc / 100 * battery.capacity_wh + initial_wh = battery.soc_wh + + for slot in (0, 0, 1): + assert battery.discharge_energy(requested_wh, slot) == (0.0, 0.0) + assert battery.soc_wh == initial_wh + + def test_battery_charge_exactly_max_soc(setup_pv_battery): battery = setup_pv_battery battery.soc_wh = battery.max_soc_wh # Set battery to exactly max SOC diff --git a/tests/test_geneticbattery.py b/tests/test_geneticbattery.py index 3e8ba70a..f2405d7d 100644 --- a/tests/test_geneticbattery.py +++ b/tests/test_geneticbattery.py @@ -114,6 +114,31 @@ def test_battery_discharge_when_empty(setup_pv_battery): assert battery.current_soc_percentage() == 20, "SoC should remain at min_soc" +@pytest.mark.parametrize("slot_duration_h", [0.25, 1.0]) +@pytest.mark.parametrize("initial_soc", [0, 5, 6]) +@pytest.mark.parametrize("requested_wh", [0.0, 280.0]) +def test_discharge_preserves_energy_at_or_below_min_soc( + slot_duration_h, initial_soc, requested_wh +): + battery = Battery( + SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=26_400, + initial_soc_percentage=initial_soc, + min_soc_percentage=6, + ), + prediction_hours=2, + slot_duration_h=slot_duration_h, + ) + battery.set_discharge_per_hour(np.ones(2)) + initial_wh = battery.soc_wh + + for slot in (0, 0, 1): + assert battery.discharge_energy(requested_wh, slot) == (0.0, 0.0) + assert battery.soc_wh == initial_wh + assert battery.discharged_energy_wh(slot) == 0.0 + + def test_battery_discharge_exactly_min_soc(setup_pv_battery): battery = setup_pv_battery battery.soc_wh = battery.min_soc_wh # Set battery to exactly min SOC diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index 2b3eba84..d38126b8 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -25,6 +25,65 @@ from akkudoktoreos.utils.datetimeutil import to_duration, to_time start_hour = 1 +@pytest.mark.parametrize("efficiency_curve", [None, [(0.0, 0.8), (1.0, 0.97)]]) +def test_below_min_soc_requires_real_charging_energy(efficiency_curve): + """Discharge attempts before and after partial charging must not create energy.""" + battery = Battery( + SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=26_400, + initial_soc_percentage=0, + min_soc_percentage=6, + max_charge_power_w=7_000, + charging_efficiency=0.95, + discharging_efficiency=0.95, + ), + prediction_hours=4, + slot_duration_h=0.25, + ) + inverter = Inverter( + InverterParameters( + device_id="inverter1", + battery_id="battery1", + max_power_wh=10_000, + max_ac_charge_power_w=6_000, + ac_to_dc_efficiency=0.97, + dc_to_ac_efficiency=0.97, + dc_to_ac_efficiency_curve=efficiency_curve, + ), + battery=battery, + slot_duration_h=0.25, + ) + simulation = GeneticSimulation() + simulation.prepare( + GeneticEnergyManagementParameters( + pv_forecast_wh=[0.0] * 4, + total_load=[280.0] * 4, + electricity_price_per_wh=[0.00037, 0.00037, 0.00050, 0.00063], + feed_in_tariff_per_wh=0.0, + price_per_wh_battery=0.0, + ), + optimization_hours=4, + prediction_hours=4, + inverter=inverter, + ) + assert simulation.ac_charge_hours is not None + assert simulation.bat_discharge_hours is not None + simulation.ac_charge_hours[:] = [0.0, 1.0, 0.0, 0.0] + simulation.bat_discharge_hours[:] = [1.0, 0.0, 1.0, 1.0] + + result = simulation.simulate(0) + + stored_wh = 1_500 * 0.97 * 0.95 + assert result["akku_soc_pro_stunde"] == pytest.approx( + [0.0, 0.0, stored_wh / 26_400 * 100, stored_wh / 26_400 * 100] + ) + assert result["Netzbezug_Wh_pro_Stunde"] == pytest.approx([280, 1780, 280, 280]) + assert battery.soc_wh == pytest.approx(stored_wh) + assert battery.soc_wh < battery.min_soc_wh + assert result["Gesamt_Verluste"] == pytest.approx(1_500 - stored_wh) + + # Example initialization of necessary components @pytest.fixture def genetic_simulation(config_eos) -> GeneticSimulation: