Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions tests/test_semicontinuous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""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)."""

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}"
Loading