diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 212b77e..3b3bf04 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -983,10 +983,7 @@ class GeneticOptimization(OptimizationBase): "Penalty function parameter `ev_soc_miss` not configured, using {}.", penalty ) ev_soc_percentage = self.simulation.ev.current_soc_percentage() - if ( - ev_soc_percentage < parameters.eauto.min_soc_percentage - or ev_soc_percentage > parameters.eauto.max_soc_percentage - ): + if ev_soc_percentage < parameters.eauto.min_soc_percentage: gesamtbilanz += ( abs(parameters.eauto.min_soc_percentage - ev_soc_percentage) * penalty ) @@ -1020,10 +1017,25 @@ class GeneticOptimization(OptimizationBase): logger.debug("Start optimize: {}", start_solution) - # Insert the start solution into the population if provided + # Insert the start solution into the population if provided and compatible with the + # currently active genome layout. EV optimization adds one gene per prediction hour, + # so a cached solution from a previous run without EV optimization must not be reused. if start_solution is not None: - for _ in range(10): - population.insert(0, creator.Individual(start_solution)) + expected_length = self.config.prediction.hours + if self.optimize_ev: + expected_length += self.config.prediction.hours + if self.opti_param.get("home_appliance", 0) > 0: + expected_length += 1 + + if len(start_solution) == expected_length: + for _ in range(10): + population.insert(0, creator.Individual(start_solution)) + else: + logger.warning( + "Ignoring start_solution with incompatible length {} (expected {}).", + len(start_solution), + expected_length, + ) # Run the evolutionary algorithm pop, log = algorithms.eaMuPlusLambda( @@ -1105,7 +1117,7 @@ class GeneticOptimization(OptimizationBase): ) eauto.set_charge_per_hour(np.full(self.config.prediction.hours, 1)) self.optimize_ev = ( - parameters.eauto.min_soc_percentage - parameters.eauto.initial_soc_percentage >= 0 + parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage ) # electrical vehicle charge rates if parameters.eauto.charge_rates is not None: @@ -1208,11 +1220,9 @@ class GeneticOptimization(OptimizationBase): if self.simulation.home_appliance: washingstart_int = self.simulation.home_appliance_start_hour - eautocharge_hours_float = ( - [self.ev_possible_charge_values[i] for i in eautocharge_hours_index] - if eautocharge_hours_index is not None - else None - ) + eautocharge_hours_float = None + if eautocharge_hours_index is not None and self.simulation.ev is not None: + eautocharge_hours_float = self.simulation.ev.charge_array.tolist() # Simulation may have changed something, use simulation values ac_charge_hours = self.simulation.ac_charge_hours diff --git a/tests/test_inverter_efficiency.py b/tests/test_inverter_efficiency.py index a07885c..2cd7b4e 100644 --- a/tests/test_inverter_efficiency.py +++ b/tests/test_inverter_efficiency.py @@ -591,6 +591,73 @@ def _run_evaluate_with_mocked_sim( return fitness[0] +def _run_evaluate_with_mocked_ev_soc(config_eos, ev_soc_percentage: float) -> float: + """Return fitness for a mocked EV SoC while EV optimization is active.""" + from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization + + config_eos.merge_settings_from_dict( + { + "prediction": {"hours": 48}, + "optimization": {"hours": 48}, + } + ) + config_eos.optimization.genetic.penalties = { + "ev_soc_miss": 10, + "ac_charge_break_even": 1.0, + } + + optim = GeneticOptimization.__new__(GeneticOptimization) + optim.config = config_eos + optim.optimize_ev = True + optim.verbose = False + optim.opti_param = {"home_appliance": 0} + + mock_ev = Mock() + mock_ev.current_soc_percentage.return_value = ev_soc_percentage + + sim = Mock() + sim.battery = None + sim.inverter = None + sim.ev = mock_ev + sim.ac_charge_hours = None + sim.elect_price_hourly = None + sim.load_energy_array = None + optim.simulation = sim + + dummy_result = { + "Gesamtbilanz_Euro": 1.0, + "Gesamt_Verluste": 0.0, + "EAuto_SoC_pro_Stunde": np.zeros(48), + } + + class _Ind(list): # noqa: N801 + pass + + fake_individual = _Ind([0] * 96) + + with patch.object(optim, "evaluate_inner", return_value=dummy_result): + fitness = optim.evaluate( + fake_individual, + parameters=Mock( + ems=Mock(preis_euro_pro_wh_akku=0.0), + eauto=Mock(min_soc_percentage=70, max_soc_percentage=100), + ), + start_hour=0, + worst_case=False, + ) + return fitness[0] + + +class TestEvSocPenalty: + """EV SoC target is a minimum-only penalty.""" + + def test_ev_soc_below_min_adds_penalty(self, config_eos): + assert _run_evaluate_with_mocked_ev_soc(config_eos, 65) == pytest.approx(51.0) + + def test_ev_soc_above_max_does_not_add_min_soc_penalty(self, config_eos): + assert _run_evaluate_with_mocked_ev_soc(config_eos, 105) == pytest.approx(1.0) + + class TestAcChargeBreakEvenPenalty: """Break-even penalty in GeneticOptimization.evaluate().