Decomposition MethodsΒΆ

[23]:
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
import numpy as np
import gurobipy as gb

%matplotlib inline
[7]:
df = pd.read_csv('https://raw.githubusercontent.com/ming-zhao/Optimization-and-Learning/master/data/benders.csv',
                 index_col='gen', skipinitialspace=True)
df
[7]:
maxprod price uppremium downpremium upflex downflex
gen
g1 80 10 2 8 0.10 0.10
g2 60 20 4 7 0.15 0.15
g3 40 30 6 6 0.20 0.20
g4 40 40 8 5 0.50 0.50
g5 50 100 10 2 1.00 1.00
[21]:
# Class which can have attributes set
class expando(object):
    pass


class Benders_Master:
    def __init__(self, max_iters=25, verbose=True, numscenarios=100, demand_avg=200.0, demand_std=20.0, epsilon=0.001, delta=0.001):
        '''
            Class which solves the benders decomposed version of the dispatch problem.

            Parameters
            ----------
            max_iters: int, default 25
                    Maximum number of Benders iterations to run.
            verbose: boolean, default True
                    Print information on upper and lower bounds for each iteration
            numscenarios: int, default 100
                    Number of scenarios to use for subproblems
            demand_avg: float, default 200.0
                    Average demand, used as day-ahead bid.
            demand_std: float, default 20.0
                    Standard deviation for demand in scenario generation.
            epsilon: float, default 0.001
                    Relative threshold for benders iterations.
                    Iterations will stop if ub - lb > |epsilon * lb|
            delta: float, default 0.001
                    Absolute threshold for benders iterations.
                    Iterations will stop if ub < lb + delta
        '''
        self.data = expando()
        self.variables = expando()
        self.constraints = expando()
        self.results = expando()
        self.params = expando()

        self.params.max_iters = max_iters
        self.params.verbose = verbose
        self.params.numscenarios = numscenarios
        self.params.demand_avg = demand_avg
        self.params.demand_std = demand_std

        self._init_benders_params(epsilon=epsilon, delta=delta)
        self._load_data()
        self._build_model()

    def optimize(self, force_submodel_rebuild=False):
        # initial solution
        self.model.optimize()

        # Only build submodels if they don't exist or a rebuild is forced.
        if not hasattr(self, 'submodels') or force_submodel_rebuild:
            self.submodels = {s: Benders_Subproblem(self, scenario=s) for s in self.data.scenarios}
        # Update fixed variables for submodels and rebuild.
        [sm.update_fixed_vars(self) for sm in self.submodels.values()]
        [sm.optimize() for sm in self.submodels.values()]

        # Update bounds based on submodel rebuild
        self._update_bounds()
        self._save_vars()
        # Build cuts until we reach absolute and relative tolerance,
        # or max_iters cuts have been generated.
        while (
            (self.data.ub > self.data.lb + self.data.delta or
             self.data.ub - self.data.lb > abs(self.data.epsilon * self.data.lb)) and
                len(self.data.cutlist) < self.params.max_iters):
            # Generate new cut.
            if self.params.verbose:
                print('********')
                print('* Benders\' step {0}:'.format(len(self.data.upper_bounds)))
                print('* Upper bound: {0}'.format(self.data.ub))
                print('* Lower bound: {0}'.format(self.data.lb))
                print('********')
            self._do_benders_step()
        pass

    def _do_benders_step(self):
            self._add_cut()
            self._start_from_previous()
            self.model.optimize()
            [sm.update_fixed_vars(self) for sm in self.submodels.values()]
            [sm.optimize() for sm in self.submodels.values()]
            self._update_bounds()
            self._save_vars()

    def _init_benders_params(self, epsilon=0.001, delta=0.001):
        self.data.cutlist = []
        self.data.upper_bounds = []
        self.data.lower_bounds = []
        self.data.mipgap = []
        self.data.solvetime = []
        self.data.alphas = []
        self.data.lambdas = {}
        self.data.epsilon = epsilon
        self.data.delta = delta
        self.data.ub = gb.GRB.INFINITY
        self.data.lb = -gb.GRB.INFINITY

    ###
    # Data Loading
    ###
    def _load_data(self):
        self._load_generator_data()
        self._load_demand_data()

    def _load_generator_data(self):
        self.data.geninfo = pd.read_csv('https://raw.githubusercontent.com/ming-zhao/Optimization-and-Learning/master/data/benders.csv', index_col='gen', skipinitialspace=True)
        self.data.generators = self.data.geninfo.index

    def _load_demand_data(self):
        self.data.VOLL = 1000
        self.data.demand_da = self.params.demand_avg
        self.data.scenarios = ['s'+str(i) for i in range(self.params.numscenarios)]
        self.data.demand_rt = pd.Series(
            data=np.random.normal(self.params.demand_avg, self.params.demand_std, size=self.params.numscenarios),
            index=self.data.scenarios)
        self.data.scenarioprobs = {s: 1.0/self.params.numscenarios for s in self.data.scenarios}
        # Dump load
        self.data.dumploadprice = 10
        self.data.dumploadmax = self.data.demand_da

    ###
    # Model Building
    ###
    def _build_model(self):
        self.model = gb.Model()
        self._build_variables()
        self._build_objective()
        self._build_constraints()
        self.model.update()

    def _build_variables(self):
        m = self.model
        gens = self.data.generators
        geninfo = self.data.geninfo

        self.variables.gprod_da = {}
        for g in gens:
            self.variables.gprod_da[g] = m.addVar(lb=0, ub=geninfo.maxprod[g])

        self.variables.load_da = m.addVar(lb=0, ub=self.data.demand_da)

        # Benders' proxy variable
        self.variables.alpha = m.addVar(lb=-self.data.demand_da*self.data.VOLL, ub=gb.GRB.INFINITY)

        m.update()

    def _build_objective(self):
        m = self.model
        gens = self.data.generators
        geninfo = self.data.geninfo

        self.objective = m.setObjective(
            gb.quicksum(geninfo.price[g] * self.variables.gprod_da[g] for g in gens) -
            self.data.VOLL*self.variables.load_da +
            self.variables.alpha)

    def _build_constraints(self):
        m = self.model
        gens = self.data.generators
        geninfo = self.data.geninfo

        self.constraints.powerbalance_da = m.addConstr(
            gb.quicksum(self.variables.gprod_da[g] for g in gens),
            gb.GRB.EQUAL,
            self.variables.load_da)

        self.constraints.cuts = {}

    def _add_cut(self):
        gens = self.data.generators
        geninfo = self.data.geninfo

        cut = len(self.data.cutlist)
        self.data.cutlist.append(cut)

        # Get sensitivities from subproblem
        sens_gen = {
            g: sum(self.data.scenarioprobs[s] * self.submodels[s].constraints.fixed_da[g].pi for s in self.data.scenarios)
            for g in gens}
        self.data.lambdas[cut] = sens_gen
        sens_load = sum(self.data.scenarioprobs[s] * self.submodels[s].constraints.fixed_load_da.pi for s in self.data.scenarios)
        # Get subproblem objectives)
        z_sub = sum(self.data.scenarioprobs[s] * self.submodels[s].model.ObjVal for s in self.data.scenarios)
        # Generate cut
        self.constraints.cuts[cut] = self.model.addConstr(
            self.variables.alpha,
            gb.GRB.GREATER_EQUAL,
            z_sub +
            gb.quicksum(sens_gen[g] * self.variables.gprod_da[g] for g in gens) -
            sum(sens_gen[g] * self.variables.gprod_da[g].x for g in gens) +
            sens_load * (self.variables.load_da - self.variables.load_da.x)
        )

    def _clear_cuts(self):
        self.data.cutlist = []
        self.data.lambdas = {}
        self.model.update()
        for con in self.constraints.cuts.values():
            self.model.remove(con)
        self.constraints.cuts = {}
        self.data.ub = gb.GRB.INFINITY
        self.data.lb = -gb.GRB.INFINITY
        self.data.upper_bounds = []
        self.data.lower_bounds = []

    ###
    # Update upper and lower bounds for Benders' iterations
    ###
    def _update_bounds(self):
        z_sub = sum(self.data.scenarioprobs[s] * self.submodels[s].model.ObjVal for s in self.data.scenarios)
        z_master = self.model.ObjVal
        # The best upper bound is the best incumbent with
        # alpha replaced by the sub problems' actual cost
        self.data.ub = z_master - self.variables.alpha.x + z_sub
        # The best lower bound is the current bestbound,
        # This will equal z_master at optimality
        try:
            self.data.lb = self.model.ObjBound
        except gb.GurobiError:
            self.data.lb = self.model.ObjVal
        self.data.upper_bounds.append(self.data.ub)
        self.data.lower_bounds.append(self.data.lb)
        self.data.mipgap.append(self.model.params.IntFeasTol)
        self.data.solvetime.append(self.model.Runtime)

    def _save_vars(self):
        # self.data.xs.append(self.variables.x.x)
        # self.data.ys.append(self.submodel.variables.y.x)
        self.data.alphas.append(self.variables.alpha.x)

    def _start_from_previous(self):
        '''
            Used to warm-start MIP problems.
        '''
        pass
[20]:
class Benders_Subproblem:
    def __init__(self, MP, scenario=0):
        self.data = expando()
        self.variables = expando()
        self.constraints = expando()
        self.results = expando()

        self.MP = MP
        self.data.scenario = scenario

        self._build_model()
        self.update_fixed_vars(MP)

    def optimize(self):
        self.model.optimize()

    ###
    #   Model Building
    ###
    def _build_model(self):
        self.model = gb.Model()
        self.model.setParam('OutputFlag', False)
        self._build_variables()
        self._build_objective()
        self._build_constraints()
        self.model.update()

    def _build_variables(self):
        m = self.model

        dumploadmax = self.MP.data.dumploadmax
        gens = self.MP.data.generators
        geninfo = self.MP.data.geninfo
        s = self.data.scenario
        demandmax = self.MP.data.demand_rt[s]

        # Production of generator g, up and downregulation
        # Up and down regulation are limited by the generators' capability.
        self.variables.gprod_da = {}
        self.variables.gprod_rt = {}
        self.variables.gprod_rt_up = {}
        self.variables.gprod_rt_down = {}
        for g in gens:
            self.variables.gprod_da[g] = m.addVar(lb=0.0, ub=geninfo.maxprod[g])
            self.variables.gprod_rt[g] = m.addVar(lb=0.0, ub=geninfo.maxprod[g])
            self.variables.gprod_rt_up[g] = m.addVar(lb=0.0, ub=geninfo.maxprod[g] * geninfo.upflex[g])
            self.variables.gprod_rt_down[g] = m.addVar(lb=0.0, ub=geninfo.maxprod[g] * geninfo.downflex[g])

        self.variables.loadserved = m.addVar(lb=0.0, ub=demandmax)
        self.variables.loadserved_DA = m.addVar(lb=0.0, ub=gb.GRB.INFINITY)
        self.variables.dumpload = m.addVar(lb=0.0, ub=dumploadmax)

        m.update()

    def _build_objective(self):
        m = self.model
        gens = self.MP.data.generators
        geninfo = self.MP.data.geninfo
        VOLL = self.MP.data.VOLL
        dumploadprice = self.MP.data.dumploadprice

        m.setObjective(
            gb.quicksum((geninfo.price[g] + geninfo.uppremium[g])*self.variables.gprod_rt_up[g] for g in gens) +
            gb.quicksum((- geninfo.price[g] + geninfo.downpremium[g])*self.variables.gprod_rt_down[g] for g in gens) -
            VOLL*(self.variables.loadserved-self.variables.loadserved_DA) +
            dumploadprice * self.variables.dumpload
        )

    def _build_constraints(self):
        m = self.model
        gens = self.MP.data.generators

        self.constraints.powerbalance_rt = m.addConstr(
            gb.quicksum(self.variables.gprod_rt[g] for g in gens),
            gb.GRB.EQUAL,
            self.variables.loadserved + self.variables.dumpload)

        self.constraints.coupling_da = {}
        self.constraints.fixed_da = {}
        for g in gens:
            self.constraints.coupling_da[g] = m.addConstr(
                self.variables.gprod_rt[g],
                gb.GRB.EQUAL,
                self.variables.gprod_da[g] + self.variables.gprod_rt_up[g] - self.variables.gprod_rt_down[g])
            self.constraints.fixed_da[g] = m.addConstr(
                self.variables.gprod_da[g],
                gb.GRB.EQUAL,
                0.0)
        self.constraints.fixed_load_da = m.addConstr(
            self.variables.loadserved_DA,
            gb.GRB.EQUAL,
            0.0)

    def update_fixed_vars(self, MP):
        for g in self.MP.data.generators:
                self.constraints.fixed_da[g].rhs = MP.variables.gprod_da[g].x
        self.constraints.fixed_load_da.rhs = MP.variables.load_da.x

[25]:
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
import numpy as np

# from benders_stochastic_master import Benders_Master

sns.set_style('ticks')

m = Benders_Master()
m.model.Params.OutputFlag = False
m.optimize()

m.model.write("test.lp")

rtdf = pd.DataFrame({g: {m.data.demand_rt[s]: m.submodels[s].variables.gprod_rt[g].x for s in m.data.scenarios} for g in m.data.generators})
updf = pd.DataFrame({g: {m.data.demand_rt[s]: m.submodels[s].variables.gprod_rt_up[g].x for s in m.data.scenarios} for g in m.data.generators})
downdf = pd.DataFrame({g: {m.data.demand_rt[s]: m.submodels[s].variables.gprod_rt_down[g].x for s in m.data.scenarios} for g in m.data.generators})

dacost = m.model.ObjVal - m.variables.alpha.x
rscostseries = pd.Series({m.data.demand_rt[s]: sum(
    m.data.geninfo.price[g]*m.submodels[s].variables.gprod_rt[g].x +
    m.data.geninfo.uppremium[g]*m.submodels[s].variables.gprod_rt_up[g].x +
    m.data.geninfo.downpremium[g]*m.submodels[s].variables.gprod_rt_down[g].x
    for g in m.data.generators) for s in m.data.scenarios})


plt.ion()

plt.figure(figsize=(12, 8))
ax = plt.subplot(221)
rtdf.plot(ax=ax, marker='.')
plt.xlabel('Realised real-time demand [MW]')
plt.ylabel('Generator setting [MW]')

ax = plt.subplot(222)
rscostseries.plot(ax=ax, marker='.')
plt.xlabel('Realised real-time demand [MW]')
plt.ylabel('Final cost [$]')

ax = plt.subplot(223)
updf.plot(ax=ax, marker='.')
plt.xlabel('Realised real-time demand [MW]')
plt.ylabel('Generator upregulation [MW]')

ax = plt.subplot(224)
downdf.plot(ax=ax, marker='.')
plt.xlabel('Realised real-time demand [MW]')
plt.ylabel('Generator downregulation [MW]')
plt.tight_layout()

m.model.Params.OutputFlag = False
m.params.verbose = False
demands = np.linspace(160, 240, 81)
costs = []
for demand in demands:
    m.variables.load_da.ub = demand
    m.variables.load_da.lb = demand
    m._clear_cuts()
    m.optimize()
    costs.append(-m.model.ObjVal)
m.variables.load_da.ub = 200
m.variables.load_da.lb = 0

plt.figure()
plt.plot(demands, costs)
plt.ylabel('Social Welfare [$]')
plt.xlabel('Demand cleared in DA market [MW]')
********
* Benders' step 1:
* Upper bound: -194905.0151374541
* Lower bound: -396000.0
********
********
* Benders' step 2:
* Upper bound: -187402.87165686628
* Lower bound: -195412.41513745408
********
********
* Benders' step 3:
* Upper bound: -190056.8887556546
* Lower bound: -195394.04089071462
********
********
* Benders' step 4:
* Upper bound: -190189.18706464546
* Lower bound: -195387.0448463883
********
********
* Benders' step 5:
* Upper bound: -191926.79858712998
* Lower bound: -195371.37543796783
********
********
* Benders' step 6:
* Upper bound: -193053.94425360794
* Lower bound: -195252.92352163696
********
********
* Benders' step 7:
* Upper bound: -193094.90138226718
* Lower bound: -195252.59889917375
********
********
* Benders' step 8:
* Upper bound: -193758.79224701668
* Lower bound: -195237.6931196585
********
********
* Benders' step 9:
* Upper bound: -193659.98456954808
* Lower bound: -195220.181321286
********
********
* Benders' step 10:
* Upper bound: -194260.30295156644
* Lower bound: -195202.9596396856
********
********
* Benders' step 11:
* Upper bound: -194459.43108348924
* Lower bound: -195111.2427179468
********
********
* Benders' step 12:
* Upper bound: -194405.62220426043
* Lower bound: -195073.99413989688
********
********
* Benders' step 13:
* Upper bound: -194510.54007723092
* Lower bound: -195072.45353457573
********
********
* Benders' step 14:
* Upper bound: -194758.666887083
* Lower bound: -195065.588551258
********
********
* Benders' step 15:
* Upper bound: -194781.09309929312
* Lower bound: -194987.81892669486
********
********
* Benders' step 16:
* Upper bound: -194817.49128958743
* Lower bound: -194980.491941937
********
********
* Benders' step 17:
* Upper bound: -194853.54839749951
* Lower bound: -194972.91244241988
********
********
* Benders' step 18:
* Upper bound: -194894.67175970715
* Lower bound: -194915.2602827549
********
********
* Benders' step 19:
* Upper bound: -194901.65770319957
* Lower bound: -194910.86229620536
********
********
* Benders' step 20:
* Upper bound: -194904.55696394757
* Lower bound: -194905.12253416877
********
********
* Benders' step 21:
* Upper bound: -194905.00495542135
* Lower bound: -194905.0276661433
********
[25]:
Text(0.5, 0, 'Demand cleared in DA market [MW]')
../_images/docs_decomposition_5_2.png
../_images/docs_decomposition_5_3.png