fix(battery): preserve energy below minimum state of charge (#1483)

This commit is contained in:
Andreas
2026-11-01 15:46:45 +01:00
committed by GitHub
parent 33fc5ed61a
commit 298d46a527
6 changed files with 107 additions and 0 deletions
@@ -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
@@ -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
)
@@ -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
+14
View File
@@ -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
+25
View File
@@ -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
+59
View File
@@ -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: