From 24096d7e2c85adedaf21d80a7b622265abfaa8f7 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 25 Jul 2026 12:17:28 +0200 Subject: [PATCH] perf: tighten indicator big-Ms to natural per-step bounds Redo of #88 on current main. The p_demand block was rewritten by #95, so the old two-disjunct tightening no longer applies. Tighten the new indicators instead: s_max for the z_p_demand and z_s_max_reached rows, the per-step c_min energy for the c_min lower bound row, per-step charge/discharge caps for z_c, z_cd and the grid coupling rows, the limit threshold itself for z_imp_lim/z_exp_lim, and solar plus discharge capacity resp. demand plus charge capacity for the flow direction indicators. Cases 013 and 019 land on a different point of the same optimal plateau, same values as #88. --- src/optimizer/optimizer.py | 51 ++++++++++++++------ test_cases/013-grid-export-limit-hit.json | 2 +- test_cases/019-unexpected-charge-spikes.json | 2 +- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index fe5e3d457..c9d34bf1d 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -408,12 +408,22 @@ def _add_energy_balance_constraints(self): == e_grid_exp + self.time_series.gt[t]) - # Constraints (4)-(5): Grid flow direction + # Constraints (4)-(5): Grid flow direction. Export/import have natural + # per-step caps (export: solar plus discharge-to-grid capacity; import: + # demand plus grid-charge capacity) that tighten the LP relaxation vs + # the global big-M without excluding any integer point. When a grid + # limit is active, the opposite side's unbounded excess variable enters + # the energy balance, so only then fall back to the global big-M. + cap_d_exp = sum(b.d_max for b in self.batteries if b.discharge_to_grid) + cap_c_imp = sum(b.c_max for b in self.batteries if b.charge_from_grid) for t in self.time_steps: + dth = self.time_series.dt[t] / 3600. + m_exp = self.time_series.ft[t] + cap_d_exp * dth if self.grid.p_max_imp is None else self.M + m_imp = self.time_series.gt[t] + cap_c_imp * dth if self.grid.p_max_exp is None else self.M # Export constraint - self.problem += self.variables['e'][t] <= self.M * self.variables['y'][t] + self.problem += self.variables['e'][t] <= m_exp * self.variables['y'][t] # Import constraint - self.problem += self.variables['n'][t] <= self.M * (1 - self.variables['y'][t]) + self.problem += self.variables['n'][t] <= m_imp * (1 - self.variables['y'][t]) # limit regular grid import power if self.grid.p_max_imp is not None: @@ -422,7 +432,8 @@ def _add_energy_balance_constraints(self): for t in self.time_steps: self.problem += self.variables['n'][t] <= self.grid.p_max_imp * self.time_series.dt[t] / 3600 self.problem += (self.grid.p_max_imp * self.time_series.dt[t] / 3600 - self.variables['n'][t] - <= self.M * self.variables['z_imp_lim'][t]) + <= self.grid.p_max_imp * self.time_series.dt[t] / 3600 + * self.variables['z_imp_lim'][t]) self.problem += (self.variables['e_imp_lim_exc'][t] <= self.M * (1 - self.variables['z_imp_lim'][t])) else: @@ -430,7 +441,8 @@ def _add_energy_balance_constraints(self): for t in self.time_steps: self.problem += self.variables['n'][t] <= self.grid.p_max_imp * self.time_series.dt[t] / 3600 self.problem += (self.grid.p_max_imp * self.time_series.dt[t] / 3600 - self.variables['n'][t] - <= self.M * self.variables['z_imp_lim'][t]) + <= self.grid.p_max_imp * self.time_series.dt[t] / 3600 + * self.variables['z_imp_lim'][t]) self.problem += (self.variables['e_imp_lim_exc'][t] <= self.M * (1 - self.variables['z_imp_lim'][t])) @@ -439,7 +451,8 @@ def _add_energy_balance_constraints(self): for t in self.time_steps: self.problem += self.variables['e'][t] <= self.grid.p_max_exp * self.time_series.dt[t] / 3600 self.problem += (self.grid.p_max_exp * self.time_series.dt[t] / 3600 - self.variables['e'][t] - <= self.M * self.variables['z_exp_lim'][t]) + <= self.grid.p_max_exp * self.time_series.dt[t] / 3600 + * self.variables['z_exp_lim'][t]) self.problem += (self.variables['e_exp_lim_exc'][t] <= self.M * (1 - self.variables['z_exp_lim'][t])) @@ -514,13 +527,14 @@ def _add_battery_constraints(self): # introduce z_p_demand to become only 1 if s_max is almost reached and the # charging with c_min would stop before the end of the time slot + # s is bounded below by 0, so s_max is the largest the left side can get self.problem += ((bat.s_max - self.variables['s'][i][t]) - <= (1 - self.variables['z_p_demand'][i][t]) * self.M + <= (1 - self.variables['z_p_demand'][i][t]) * bat.s_max + bat.c_min * self.time_series.dt[t] / 3600.) # introduce z_s_max_reached to become only 1 if s_max is fully reached self.problem += ((bat.s_max - self.variables['s'][i][t]) - <= (1 - self.variables['z_s_max_reached'][i][t]) * self.M) + <= (1 - self.variables['z_s_max_reached'][i][t]) * bat.s_max) # set a "soft" constraint to reach the requested charging rate if possible. # deactivate it if s_max is already reached @@ -532,9 +546,11 @@ def _add_battery_constraints(self): if bat.c_min > 0: # set constraints to exclude charging between 0 and c_min if z_p_demand is not 1. - self.problem += (self.variables['c'][i][t] + self.variables['z_p_demand'][i][t] * self.M + self.problem += (self.variables['c'][i][t] + + bat.c_min * self.time_series.dt[t] / 3600. * self.variables['z_p_demand'][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]) + self.problem += (self.variables['c'][i][t] <= bat.c_max * self.time_series.dt[t] / 3600. + * self.variables['z_c'][i][t]) # Constraint (7): Minimum charge power limits if there is no charge demand elif bat.c_min > 0: @@ -542,24 +558,29 @@ def _add_battery_constraints(self): # 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]) + self.problem += (self.variables['c'][i][t] <= bat.c_max * self.time_series.dt[t] / 3600. + * self.variables['z_c'][i][t]) # control battery charging from grid if not bat.charge_from_grid: for t in self.time_steps: - self.problem += (self.variables['c'][i][t] <= self.M * self.variables['y'][t]) + self.problem += (self.variables['c'][i][t] <= bat.c_max * self.time_series.dt[t] / 3600. + * self.variables['y'][t]) # control battery discharging to grid if not bat.discharge_to_grid: for t in self.time_steps: - self.problem += (self.variables['d'][i][t] <= self.M * (1 - self.variables['y'][t])) + 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] <= self.M * self.variables['z_cd'][i][t] + 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] <= self.M * (1 - self.variables['z_cd'][i][t]) + 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 4992181b2..53863f505 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.529475918803602, + "objective_value": 17.5372629588039, "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 b554b9709..b61ffda0a 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.2393735662576777, + "objective_value": 1.2481766551776774, "limit_violations": { "grid_import_limit_exceeded": false, "grid_export_limit_hit": false