fix(optimization): clear disabled AC charge in the reported plan

With grid charging switched off (inverter.max_ac_charge_power_w = 0) the
simulation zeroes the AC charge array before using it, but it did so by
rebinding a local name. The solution is read back from the original array,
so it still carried the optimizer's AC charge genes - genes the fitness
never evaluated, because the simulation ignored them, and which are
therefore arbitrary.

The effect was visible as a slot with ac_charge = 0.8 where the battery SoC
does not move. Harmless inside EOS, but a controller that follows the plan
would grid-charge the battery at a time nobody planned or paid for.

Zero the array in place so the reported plan matches what was simulated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Andreas
2026-09-03 18:56:42 +02:00
co-authored by Claude Opus 5
parent a6b20e6de2
commit 2a9543e710
3 changed files with 69 additions and 2 deletions
+4
View File
@@ -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
@@ -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
+59
View File
@@ -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]