diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index b878ec8e6..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): """ @@ -367,12 +373,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: @@ -381,7 +397,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: @@ -389,7 +406,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])) @@ -398,7 +416,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])) @@ -417,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]) @@ -451,20 +472,24 @@ def _add_battery_constraints(self): # clip required charge to max charging power if needed # and leave some air to breathe for the optimizer p_demand = min(bat.c_max * self.time_series.dt[t] / 3600., bat.p_demand[t]) - # two alternative constraints, only one is active: + # two alternative constraints, only one is active + # (p_demand deactivates option 1, s_max option 2 — + # the smallest constants that keep the inactive + # disjunct vacuous, far tighter than the global M): # constraint option 1: charge energy tries to reach min charge energy parameter self.problem += (self.variables['c'][i][t] + self.variables['p_demand_pen'][i][t] - + self.M * self.variables['z_p_demand'][i][t] >= p_demand) + + p_demand * self.variables['z_p_demand'][i][t] >= p_demand) # constraint option 2: charge energy tries to reach energy to fill the battery to s_max self.problem += (self.variables['c'][i][t] + self.variables['p_demand_pen'][i][t] - + self.M * (1 - self.variables['z_p_demand'][i][t]) + + bat.s_max * (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]) + 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 not charge demand elif bat.c_min > 0: @@ -472,24 +497,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..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.529475918803602, + "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 b554b9709..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.2393735662576777, + "objective_value": 1.2393735754976773, "limit_violations": { "grid_import_limit_exceeded": false, "grid_export_limit_hit": false