diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e0c6fc..6e2ee5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). two applies. The `ev_soc_miss` penalty is then evaluated at that slot instead of at the end of the horizon, and the seeding heuristics only propose charge slots before it. Without a deadline the behaviour is unchanged. +- Fix: with grid charging disabled (`inverter.max_ac_charge_power_w = 0`) the returned + `ac_charge` array kept the optimizer's unused gene values. The simulation ignored them, so + they were never costed - but a controller acting on the plan would grid-charge the battery + anyway. The disabled AC charge is now cleared in the reported plan as well. - EV Bug (wrong output in genetic.py / no senseful results) - Direktvermarktung active / Battery discharge into grid (new state / action battery_grid_export_allowed) + (new simulation output Feed_in_tariff) - New PV forecast providers giving operators more cloud forecast sources to choose from in diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index f819ecc1..c40e8fe7 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -350,9 +350,13 @@ class GeneticSimulation(PydanticBaseModel): max_ac_charge_w_fast is None or max_ac_charge_w_fast > 0 ) - # If AC charging is disabled via inverter, zero out AC charge hours + # If AC charging is disabled via inverter, zero out AC charge hours. + # In place, not by rebinding: the reported plan is read back from + # this very array, so a rebind would leave AC charge values in the + # solution that the simulation never executed - and a controller + # acting on them would grid-charge the battery unplanned. if not ac_charging_possible: - ac_charge_hours_fast = np.zeros_like(ac_charge_hours_fast) + ac_charge_hours_fast[:] = 0.0 # Fill the charge array of the battery dc_charge_hours_fast[0:start_hour] = 0 diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index a000b5a5..4dac0b5c 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -560,3 +560,62 @@ def test_battery_lcos_is_charged_once_on_delivered_energy(config_eos): assert result["Gesamtkosten_Euro"] == pytest.approx(0.06) assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.08) assert result["Gesamtbilanz_Euro"] == pytest.approx(-0.02) + + +def test_disabled_ac_charging_clears_the_reported_plan(config_eos): + """With AC charging off the reported plan must not keep charge commands. + + The simulation ignores the AC charge genes when the inverter forbids grid + charging. The solution is read back from the same array, so a controller + acting on it would grid-charge the battery although no such charge was ever + simulated or paid for. + """ + config_eos.merge_settings_from_dict( + {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} + ) + + battery = Battery( + SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=10000, + initial_soc_percentage=50, + min_soc_percentage=0, + charging_efficiency=1.0, + discharging_efficiency=1.0, + max_charge_power_w=5000, + ), + prediction_hours=config_eos.prediction.hours, + ) + inverter = Inverter( + InverterParameters( + device_id="inverter1", + max_power_wh=5000.0, + battery_id=battery.parameters.device_id, + max_ac_charge_power_w=0, # Netzladen deaktiviert + ), + battery=battery, + ) + + simulation = GeneticSimulation() + simulation.prepare( + GeneticEnergyManagementParameters( + pv_prognose_wh=[0.0, 0.0], + strompreis_euro_pro_wh=[0.0003, 0.0003], + einspeiseverguetung_euro_pro_wh=[0.0001, 0.0001], + preis_euro_pro_wh_akku=0.0, + gesamtlast=[0.0, 0.0], + ), + optimization_hours=config_eos.optimization.horizon_hours, + prediction_hours=config_eos.prediction.hours, + inverter=inverter, + ) + simulation.ac_charge_hours = np.array([0.8, 0.0]) + + soc_before = battery.current_soc_percentage() + simulation.simulate(start_hour=0) + + # Nothing was charged ... + assert battery.current_soc_percentage() == pytest.approx(soc_before) + # ... and the plan says so. + assert simulation.ac_charge_hours is not None + assert list(simulation.ac_charge_hours) == [0.0, 0.0]