From 4b600dc53ebae0e210908d9979a2de13473c7cf6 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 16 Jul 2026 08:34:37 +0200 Subject: [PATCH] perf: scale solver objective 1e4; hard SOC bounds for in-range starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that compound with the tightened big-Ms underneath: Objective scaling: the optimum sits at ~0.5-18 currency units with tie-break coefficients down at 1e-8, so CBC's absolute tolerances (integrality 1e-6, cutoff increment 1e-5) cannot separate near-tie nodes and the search wanders the alternate-optima plateau. Scaling the solver-facing objective by 1e4 lets those tolerances bite; reported values are recomputed from variable values and stay unscaled. Hard SOC bounds: when s_initial lies within [s_min, s_max], the soft penalty construction (2 penalty columns + 2 rows per battery per step) is dead weight — the penalty (0.27/Wh) exceeds any achievable gain (<3e-4/Wh) by three orders of magnitude, so the penalties are zero at every optimum and [s_min, s_max] can be variable bounds instead. The soft path remains for out-of-range starts (cases 015/018). Full suite (bundled CBC, single-thread, median of 3): total 1.70s -> 1.36s vs the big-M base, 1.93s -> 1.36s vs main; every case equal or better than main (021 root-solves again: 0 nodes/0.09s, healing the base branch's 10-node drift; 017 root-solves; 016 1256 -> 189 iterations). Full objectives match the base to <= 3e-6. Golden objective_value for 013/019 re-lands on other plateau points (019 back to main's original value) — optimum unchanged. Co-Authored-By: Claude Fable 5 --- src/optimizer/optimizer.py | 12 ++++++++++-- test_cases/013-grid-export-limit-hit.json | 2 +- test_cases/019-unexpected-charge-spikes.json | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index fa7eeb17c..4ac1b98ff 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -141,7 +141,10 @@ def _setup_variables(self): self.variables['s'] = {} for i, bat in enumerate(self.batteries): self.variables['s'][i] = [ - pulp.LpVariable(f"s_{i}_{t}", lowBound=0, upBound=bat.s_capacity) + pulp.LpVariable( + f"s_{i}_{t}", + lowBound=bat.s_min if bat.s_min <= bat.s_initial <= bat.s_max else 0, + upBound=bat.s_max if bat.s_min <= bat.s_initial <= bat.s_max else bat.s_capacity) for t in self.time_steps ] @@ -323,7 +326,10 @@ def _setup_target_function(self): objective += self.variables['c'][i][t] * self.min_import_price * 5e-5 * (self.T - t) * bat.c_priority objective += self.variables['d'][i][t] * self.min_import_price * 5e-5 * (self.T - t) * bat.c_priority - self.problem += objective + # scale the solver-facing objective so CBC's absolute tolerances + # (integrality 1e-6, cutoff increment 1e-5) can separate near-ties; + # reported values are recomputed from variables and stay unscaled + self.problem += objective * 1e4 def _add_energy_balance_constraints(self): """ @@ -430,6 +436,8 @@ def _add_battery_constraints(self): # greater than the maximum SOC or lesser than min SOC, maximum discharging is forced until the max. # SOC is reached or max. charing will be forced until min SOC is reached. for i, bat in enumerate(self.batteries): + if bat.s_min <= bat.s_initial <= bat.s_max: + continue # SOC kept in range by hard variable bounds for t in range(0, self.T): self.problem += (self.variables['s_max_pen'][i][t] >= self.variables['s'][i][t] - bat.s_max) self.problem += (self.variables['s_min_pen'][i][t] >= bat.s_min - self.variables['s'][i][t]) 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 diff --git a/test_cases/019-unexpected-charge-spikes.json b/test_cases/019-unexpected-charge-spikes.json index b61ffda0a..5d00cc867 100644 --- a/test_cases/019-unexpected-charge-spikes.json +++ b/test_cases/019-unexpected-charge-spikes.json @@ -274,7 +274,7 @@ }, "expected_response": { "status": "Optimal", - "objective_value": 1.2481766551776774, + "objective_value": 1.2393735754976773, "limit_violations": { "grid_import_limit_exceeded": false, "grid_export_limit_hit": false