From 46a89a6f390d6fbefae00fde980531864e4f74c3 Mon Sep 17 00:00:00 2001 From: Andreas <35328755+drbacke@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:05:44 +0200 Subject: [PATCH] fix(devices): let EVs finish charging within the final slot (#1423) --- src/akkudoktoreos/devices/genetic/battery.py | 16 +++- tests/test_ev_minimum_current.py | 78 ++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tests/test_ev_minimum_current.py diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index 640c1bfc..b51f28f6 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -418,9 +418,10 @@ class Battery: - `wh is None` and `charge_factor > 0` - The raw requested energy is `max_charge_power_w * charge_factor`. - - If the request exceeds remaining capacity, the algorithm tries to find a lower - `charge_factor` that is compatible. If such a charge factor exists, this hour’s - `charge_factor` is replaced. + - An electric vehicle may finish within a slot at the requested current; + its accepted energy is capped at the remaining capacity. + - For stationary batteries, requests above the remaining capacity try a lower + compatible `charge_factor`, replacing this slot's factor. - If no charge factor can accommodate charging, the request is ignored (``(0.0, 0.0)`` is returned) and a penalty is applied elsewhere. @@ -485,7 +486,14 @@ class Battery: elif wh is None and charge_factor > 0.0: # mode 2 raw_request_wh = max_charge_per_slot_wh_fast * charge_factor raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast - if raw_request_wh > raw_charge_wh: + if isinstance(self.parameters, ElectricVehicleParameters): + # The vehicle BMS ends charging when full, even within a slot. + # Keep the feasible pilot/current setting; cap only the energy + # below instead of inventing a current below the EV minimum. + if raw_charge_wh <= 0.0: + self.charge_array[hour] = 0 + return 0.0, 0.0 + elif raw_request_wh > raw_charge_wh: # Use a lower charge factor: the rates below the requested # one, highest first (as _lower_charge_rates_desc yields them). requested_charge_factor = charge_factor diff --git a/tests/test_ev_minimum_current.py b/tests/test_ev_minimum_current.py new file mode 100644 index 00000000..3261c875 --- /dev/null +++ b/tests/test_ev_minimum_current.py @@ -0,0 +1,78 @@ +import unittest + +import numpy as np + +from akkudoktoreos.devices.genetic.battery import ( + Battery, + ElectricVehicleParameters, + SolarPanelBatteryParameters, +) + +RATES = [0.0] + [amps / 16 for amps in range(6, 17)] + + +def battery(kind=ElectricVehicleParameters, soc=99, maximum=100): + parameters = kind( + device_id="ev-test", capacity_wh=60000, + initial_soc_percentage=int(soc), charging_efficiency=0.95, + discharging_efficiency=1.0, max_charge_power_w=11040, + max_soc_percentage=maximum, charge_rates=RATES, + ) + value = Battery(parameters, prediction_hours=4, slot_duration_h=0.25) + value.reset() + value.soc_wh = soc / 100 * value.capacity_wh + value.charge_array = np.full(4, 0.375) + return value + + +class TestEVMinimumCurrent(unittest.TestCase): + def test_ev_finishes_within_final_slot_at_unchanged_minimum_current(self): + for soc in [98.5, 99.0, 99.9]: + with self.subTest(soc=soc): + value = battery(soc=soc) + missing = 60000 - value.soc_wh + stored, losses = value.charge_energy(None, 0, charge_factor=0.375) + self.assertAlmostEqual(stored, missing) + self.assertAlmostEqual(value.soc_wh, 60000) + self.assertAlmostEqual(losses, missing / 0.95 - missing) + self.assertEqual(value.charge_array[0], 0.375) + self.assertLessEqual(stored + losses, 4140 * 0.25) + + def test_full_ev_has_no_charge_command_or_energy(self): + value = battery(soc=100) + self.assertEqual(value.charge_energy(None, 0, charge_factor=0.375), (0.0, 0.0)) + self.assertEqual(value.charge_array[0], 0) + + def test_complete_minimum_current_slot_preserves_watt_hours(self): + value = battery(soc=80) + stored, losses = value.charge_energy(None, 0, charge_factor=0.375) + self.assertAlmostEqual(stored + losses, 4140 * 0.25) + self.assertAlmostEqual(stored, 4140 * 0.25 * 0.95) + + def test_ev_respects_a_lower_configured_maximum(self): + value = battery(soc=95.9, maximum=96) + value.charge_energy(None, 0, charge_factor=0.375) + self.assertAlmostEqual(value.soc_wh, 60000 * 0.96) + + def test_stationary_battery_keeps_discrete_rate_behavior(self): + value = battery(SolarPanelBatteryParameters, soc=99) + self.assertEqual(value.charge_energy(None, 0, charge_factor=0.375), (0.0, 0.0)) + self.assertEqual(value.soc_wh, 59400) + self.assertEqual(value.charge_array[0], 0) + + def test_final_slot_preserves_each_configured_current(self): + for amps in range(6, 17): + with self.subTest(amps=amps): + value = battery(soc=99.9) + factor = amps / 16 + value.charge_array[0] = factor + missing = value.max_soc_wh - value.soc_wh + stored, losses = value.charge_energy(None, 0, charge_factor=factor) + self.assertAlmostEqual(stored, missing) + self.assertAlmostEqual(value.soc_wh, value.max_soc_wh) + self.assertAlmostEqual(losses, missing / 0.95 - missing) + self.assertEqual(value.charge_array[0], factor) + + +if __name__ == "__main__": + unittest.main()