Consider a long-term multi-year investment problem where CSP (Concentrated Solar Power) has a learning curve such that
$$LCOE = c_0 \left(\frac{x_t}{x_0}\right)^{-\gamma} + c_1$$where $c_0$ is cost at start, $c_1$ is material cost and $x_t$ is cumulative capacity in the investment interval $t$. Thus, $x_0$ is the initial cumulative CSP capacity.
Additionally, there are nuclear and coal generators for which there is no potential for reducing their LCOE.
We build an optimisation to minimise the cost of supplying a flat demand $d=100$ GW with the given technologies between 2020 and 2050, where a CO$_2$ budget cap is applied.
Hint: Problem formulation is to be found further along this notebook.
Task: Explore different discount rates, learning rates, CO$_2$ budgets. For instance
NB The learning curve coupling makes the problem non-linear, so you need to install the non-linear interior-point solver ipopt:
conda install -c conda-forge ipopt
Copyright 2019 Tom Brown (KIT)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
from pyomo.environ import ConcreteModel, Var, Objective, NonNegativeReals, Constraint, Suffix, exp
from pyomo.opt import SolverFactory
import pandas as pd
import matplotlib.pyplot as plt
plt.style.use('bmh')
%matplotlib inline
techs = ["coal","nuclear","CSP"]
colors = ["#707070","#ff9000","#f9d002"]
parameters = pd.DataFrame(columns=techs)
parameters.loc["current annuity"] = [15.*8760,65.*8760,150.*8760] # EUR/MW/a
parameters.loc["potential annuity"] = [15.*8760,65.*8760,35.*8760] # EUR/MW/a
parameters.loc["learning parameter"] = [0.,0.,0.333]
parameters.loc["marginal cost"] = [35.,10.,0.] #EUR/MWhel
parameters.loc["specific emissions"] = [1.,0.,0.] #tcO2/MWhel
parameters.loc["lifetime"] = [40,40,30] #years
parameters.loc["existing age"] = [20,0,0] #years
parameters.loc["existing capacity"] = [100,0,0] #GW
parameters.loc["current LCOE"] = parameters.loc["current annuity"]/8760 + parameters.loc["marginal cost"]
parameters.loc["potential LCOE"] = parameters.loc["potential annuity"]/8760 + parameters.loc["marginal cost"]
parameters
#discount rate
rate = 0.05
#demand in GW
demand = 100.
# considered years
years = list(range(2020,2070))
scenario = "no_co2-no_learning"
scenario = "co2-0p2-no_learning"
scenario = "co2-0p2-learning"
if "no_learning" in scenario:
parameters.loc["learning parameter"] = 0
else:
parameters.at["learning parameter","CSP"] = 0.333
# carbon budget in average tCO2/MWh_el
if "no_co2" in scenario:
co2_budget = 2.
else:
co2_budget = 0.2
model = ConcreteModel("discounted total costs")
Generator capacity available for tech $s$ in year $a$ $$G_{s,a}$$
model.generators = Var(techs, years, within=NonNegativeReals)
Generator dispatch for tech $s$ in year $a$ $$g_{s,a}$$
model.generators_dispatch = Var(techs, years, within=NonNegativeReals)
New capacity built for tech $s$ in year $a$ $$Q_{s,a}$$
model.generators_built = Var(techs, years, within=NonNegativeReals)
model.fixed_costs = Var(techs, years, within=NonNegativeReals)
The objective is to minimise the system costs:
$$\min \quad \sum_{s\in S, a\in A} \frac{1}{10^6\cdot (1+r)^{a}} \left( o_{s,a} \cdot g_{s,a} \cdot 8760 + \sum_{b} c_{s,b} Q_{s,b} \mathbb{I}(a \geq b) \mathbb{I}(a < b+L_s) \right) $$# in billion EUR
# annuities from existing generators
# in billion (MW to GW *1e3, then devide by 1e9)
constant =sum(parameters.at["existing capacity",tech]*parameters.at["current annuity",tech]/1e6/(1+rate)**(year-years[0]) for tech in techs for year in years if year < years[0] + parameters.at["lifetime",tech] - parameters.at["existing age",tech])
print(constant)
model.objective = Objective(expr=constant +
sum(model.generators_built[tech,year]*model.fixed_costs[tech,year]/1e6*sum(1/(1+rate)**(yearb-years[0]) for yearb in years if ((yearb>= year) and (yearb < year + parameters.at["lifetime",tech])))
for year in years
for tech in techs) +
sum(model.generators_dispatch[tech,year]*parameters.at["marginal cost",tech]*8760/1e6/(1+rate)**(year-years[0])
for year in years
for tech in techs))
Add a constraint such that demand is met by generator dispatch:
$$\forall a\in A: \quad d = \sum_{s \in S} g_{s,a}$$def balance_constraint(model, year):
return demand == sum(model.generators_dispatch[tech,year] for tech in techs)
model.balance_constraint = Constraint(years, rule=balance_constraint)
def generator_constraint(model, tech, year):
return model.generators_dispatch[tech,year] <= model.generators[tech,year]
model.generator_constraint = Constraint(techs, years, rule=generator_constraint)
Add a constraint on carbon dioxide emissions:
$$\sum_{s\in S, a\in A} G_{s,a} \cdot e_{t} \leq \hat{e} \cdot |A| \cdot d$$def co2_constraint(model):
return co2_budget*len(years)*demand >= sum(model.generators_dispatch[tech,year]*parameters.at["specific emissions",tech] for tech in techs for year in years)
model.co2_constraint = Constraint(rule=co2_constraint)
def fixed_cost_constraint(model,tech,year):
if parameters.at["learning parameter",tech] == 0:
return model.fixed_costs[tech,year] == parameters.at["current annuity",tech]
else:
return model.fixed_costs[tech,year] == parameters.at["potential annuity",tech] + (parameters.at["current annuity",tech]-parameters.at["potential annuity",tech])*(1+sum(model.generators[tech,yeart] for yeart in years if yeart < year))**(-parameters.at["learning parameter",tech])
model.fixed_cost_constraint = Constraint(techs, years, rule=fixed_cost_constraint)
def build_years(model,tech,year):
if year < years[0] + parameters.at["lifetime",tech] - parameters.at["existing age",tech]:
constant = parameters.at["existing capacity",tech]
else:
constant = 0.
return model.generators[tech,year] == constant + sum(model.generators_built[tech,yearb] for yearb in years if ((year>= yearb) and (year < yearb + parameters.at["lifetime",tech])))
model.build_years = Constraint(techs, years, rule=build_years)
Hint: You can print the model formulation with
model.pprint()
opt = SolverFactory("ipopt")
results = opt.solve(model,suffixes=["dual"],keepfiles=False)
Optimised cost (in billion euros NPV):
print(model.objective())
The unoptimized cost (where everything is supplied by coal) is:
print(8760*demand*parameters.at["current LCOE","coal"]*len(years)/1e6)
dispatch = pd.DataFrame(0.,index=years,columns=techs)
for year in years:
for tech in techs:
dispatch.at[year,tech] = model.generators_dispatch[tech,year].value
fig, ax = plt.subplots()
fig.set_size_inches((10,6))
dispatch.plot(kind="area",stacked=True,color=colors,ax=ax,linewidth=0)
ax.set_xlabel("year")
ax.set_ylabel("dispatch [GW]")
fig.tight_layout()
fig.savefig("{}-dispatch.pdf".format(scenario),transparent=True)
Plotting the development of the technology mix of the optimal solution over time:
capacities = pd.DataFrame(0.,index=years,columns=techs)
for year in years:
for tech in techs:
capacities.at[year,tech] = model.generators[tech,year].value
fig, ax = plt.subplots()
fig.set_size_inches((10,6))
capacities.plot(kind="area",stacked=True,color=colors,ax=ax,linewidth=0)
ax.set_xlabel("year")
ax.set_ylabel("capacity [GW]")
fig.tight_layout()
fig.savefig("{}-capacity.pdf".format(scenario),transparent=True)
build_years = pd.DataFrame(0.,index=years,columns=techs)
for year in years:
for tech in techs:
build_years.at[year,tech] = model.generators_built[tech,year].value
fig, ax = plt.subplots()
fig.set_size_inches((10,6))
build_years.plot(kind="area",stacked=True,color=colors,ax=ax,linewidth=0)
ax.set_xlabel("year")
ax.set_ylabel("new capacity built [GW]")
fig.tight_layout()
fig.savefig("{}-new_capacity.pdf".format(scenario),transparent=True)
Plotting the development of the costs of the technology over time:
costs = pd.DataFrame(0.,index=years,columns=techs)
for year in years:
for tech in techs:
costs.at[year,tech] = model.fixed_costs[tech,year].value/8760. + parameters.at["marginal cost",tech]
fig, ax = plt.subplots()
fig.set_size_inches((10,6))
costs.plot(color=colors,ax=ax,linewidth=3)
ax.set_xlabel("year")
ax.set_ylabel("LCOE [EUR/MWh]")
ax.set_ylim([0,160])
fig.tight_layout()
fig.savefig("{}-lcoe.pdf".format(scenario),transparent=True)