From c409db4044d05004589793a1e17e55d1353aab12 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 15 Jul 2026 23:44:25 +0200 Subject: [PATCH] perf: skip charge/discharge lock binaries for one-directional batteries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A battery with d_max=0 (or c_max=0) cannot have simultaneous charge and discharge, so its z_cd lock pair is vacuous — yet it contributed one binary and two rows per time step (154 binaries on case 020, a quarter of the model's integer variables). Follow the existing z_c pattern: create z_cd only when the battery can do both, None otherwise. Case 013's expected objective_value moves again for the same reason as in the big-M change underneath: the constraints removed are vacuous, so the optimum is unchanged (side-by-side full-objective check agrees to all printed digits), but the penalty-free decomposition is not unique across the optimal plateau and the solution lands elsewhere on it. Co-Authored-By: Claude Fable 5 --- src/optimizer/optimizer.py | 34 ++++++++++++++--------- test_cases/013-grid-export-limit-hit.json | 2 +- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index fa7eeb17c..c1c375572 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -211,13 +211,19 @@ def _setup_variables(self): else: self.variables['z_c'][i] = None - # Binary variable to lock charging against discharging + # Binary variable to lock charging against discharging. A battery + # that cannot discharge (d_max=0) or cannot charge (c_max=0) needs no + # lock: the constraint pair is vacuous, and the binaries only add + # integrality work for the solver. self.variables['z_cd'] = {} for i, bat in enumerate(self.batteries): - self.variables['z_cd'][i] = [ - pulp.LpVariable(f"z_cd_{i}_{t}", cat='Binary') - for t in self.time_steps - ] + if bat.c_max > 0 and bat.d_max > 0: + self.variables['z_cd'][i] = [ + pulp.LpVariable(f"z_cd_{i}_{t}", cat='Binary') + for t in self.time_steps + ] + else: + self.variables['z_cd'][i] = None def _setup_target_function(self): """ @@ -504,14 +510,16 @@ def _add_battery_constraints(self): self.problem += (self.variables['d'][i][t] <= bat.d_max * self.time_series.dt[t] / 3600. * (1 - self.variables['y'][t])) - # lock charging against discharging - for t in self.time_steps: - # Discharge constraint - self.problem += (self.variables['d'][i][t] <= bat.d_max * self.time_series.dt[t] / 3600. - * self.variables['z_cd'][i][t]) - # Charge constraint - self.problem += (self.variables['c'][i][t] <= bat.c_max * self.time_series.dt[t] / 3600. - * (1 - self.variables['z_cd'][i][t])) + # lock charging against discharging (skipped when the battery + # cannot both charge and discharge: nothing to lock) + if self.variables['z_cd'][i] is not None: + for t in self.time_steps: + # Discharge constraint + self.problem += (self.variables['d'][i][t] <= bat.d_max * self.time_series.dt[t] / 3600. + * self.variables['z_cd'][i][t]) + # Charge constraint + self.problem += (self.variables['c'][i][t] <= bat.c_max * self.time_series.dt[t] / 3600. + * (1 - self.variables['z_cd'][i][t])) def solve(self) -> Dict: """ diff --git a/test_cases/013-grid-export-limit-hit.json b/test_cases/013-grid-export-limit-hit.json index 53863f505..4a3d34bcf 100644 --- a/test_cases/013-grid-export-limit-hit.json +++ b/test_cases/013-grid-export-limit-hit.json @@ -199,7 +199,7 @@ }, "expected_response": { "status": "Optimal", - "objective_value": 17.5372629588039, + "objective_value": 17.531374666979104, "limit_violations": { "grid_import_limit_exceeded": false, "grid_export_limit_hit": true