From ae132de81cf0daeaca789912481e1ae3d4bd246b Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 18 Jul 2026 11:19:19 +0200 Subject: [PATCH 1/2] fix: enforce minimum charge power in charge-demand slots The semi-continuous charge constraint (c is 0 or >= c_min) was only applied in slots without a charge demand. In demand slots (p_demand[t] > 0) only the demand constraints ran, so the solver could return any power in (0, c_min) - e.g. ~950 W for a charger whose real minimum is far higher (evcc-io/evcc#31894). Apply the semi-continuous bound in every time step, independent of demand. --- src/optimizer/optimizer.py | 14 +++------ tests/test_semicontinuous.py | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 tests/test_semicontinuous.py diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index b878ec8e6..37119ed35 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -459,15 +459,11 @@ def _add_battery_constraints(self): self.problem += (self.variables['c'][i][t] + self.variables['p_demand_pen'][i][t] + self.M * (1 - self.variables['z_p_demand'][i][t]) - (self.batteries[i].s_max - self.variables['s'][i][t]) >= 0.) - elif bat.c_min > 0: - # in time steps without given charging demand, apply normal lower bound: - # Lower bound: either 0 or at least c_min - self.problem += (self.variables['c'][i][t] >= bat.c_min * self.time_series.dt[t] / 3600. - * self.variables['z_c'][i][t]) - self.problem += (self.variables['c'][i][t] <= self.M * self.variables['z_c'][i][t]) - - # Constraint (7): Minimum charge power limits if there is not charge demand - elif bat.c_min > 0: + + # Constraint (7): Semi-continuous charge power - c is either 0 or >= c_min. + # Applied in every time step, including charge-demand slots, so a real charger's + # minimum current is respected and the solver cannot pick sub-minimum power. + if bat.c_min > 0: for t in self.time_steps: # Lower bound: either 0 or at least c_min self.problem += (self.variables['c'][i][t] >= bat.c_min * self.time_series.dt[t] / 3600. diff --git a/tests/test_semicontinuous.py b/tests/test_semicontinuous.py new file mode 100644 index 000000000..46e6d60db --- /dev/null +++ b/tests/test_semicontinuous.py @@ -0,0 +1,60 @@ +"""Regression: charge power must be semi-continuous (0 or >= c_min) in every +slot, including charge-demand slots. Previously the min-charge constraint was +skipped whenever p_demand[t] > 0, letting the solver return sub-minimum power +(e.g. ~950 W for a charger whose real minimum is far higher).""" + +import numpy + +from optimizer.app import app + +C_MIN = 1400.0 # W, e.g. 1p x 230V x ~6A + + +def _request(p_demand): + # 3 x 1h slots; no solar, charging only from priced grid, stored energy + # worthless (p_a=0). Charging is therefore strictly costly, so the solver + # charges the minimum needed to meet p_demand - which (pre-fix) is the + # sub-c_min demand value itself. + return { + "batteries": [ + { + "charge_from_grid": True, + "s_min": 0.0, + "s_max": 10000.0, + "s_initial": 0.0, + "p_demand": p_demand, + "c_min": C_MIN, + "c_max": 11000.0, + "d_max": 0.0, + "p_a": 0.0, + } + ], + "time_series": { + "dt": [3600.0, 3600.0, 3600.0], + "gt": [0.0, 0.0, 0.0], + "ft": [0.0, 0.0, 0.0], # no solar -> charge only from priced grid + "p_N": [0.30, 0.30, 0.30], # expensive grid import + "p_E": [0.0, 0.0, 0.0], # export worthless + }, + } + + +def _charge_power(response): + dt = response.json # marshalled result + powers = dt["batteries"][0]["charging_power"] # Wh per 1h slot == W + return powers + + +def test_charge_demand_below_cmin_is_not_sub_minimum(): + # demand 500 Wh in slot 0 is BELOW c_min energy (1400 Wh) -> the trap + client = app.test_client() + resp = client.post("/optimize/charge-schedule", json=_request([500.0, 0.0, 0.0])) + assert resp.status_code == 200 + + powers = _charge_power(resp) + for w in powers: + assert w < 1.0 or w >= C_MIN - 1.0, f"sub-minimum charge power {w} W (c_min={C_MIN})" + + # non-vacuous: the demand must actually drive charging (>= c_min), otherwise + # the assertion above passes trivially on an all-zero schedule. + assert max(powers) >= C_MIN - 1.0, f"expected charging >= c_min, got {powers}" From c2133aac2b3a92e4bf6946f39fceb8fc2cd7f160 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 18 Jul 2026 11:23:32 +0200 Subject: [PATCH 2/2] test: drop unused numpy import (ruff F401) --- tests/test_semicontinuous.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_semicontinuous.py b/tests/test_semicontinuous.py index 46e6d60db..87a915540 100644 --- a/tests/test_semicontinuous.py +++ b/tests/test_semicontinuous.py @@ -3,8 +3,6 @@ skipped whenever p_demand[t] > 0, letting the solver return sub-minimum power (e.g. ~950 W for a charger whose real minimum is far higher).""" -import numpy - from optimizer.app import app C_MIN = 1400.0 # W, e.g. 1p x 230V x ~6A