Source code for el1xr_opt.Modules.oM_Investment

# Developed by: Erik F. Alvarez
#
# Electric Power System Unit
# RISE
# erik.alvarez@ri.se
#
# Investment (capacity-sizing) layer for el1xr_opt.
#
# The capacity-expansion formulation follows the generation and storage
# investment approach of the openTEPES model:
#   A. Ramos, E. F. Alvarez, S. Lumbreras, "openTEPES: Open-source Transmission
#   and Generation Expansion Planning," SoftwareX 18 (2022) 101070.
#   https://doi.org/10.1016/j.softx.2022.101070
#
# DRAFT FOR REVIEW. Before solving with this layer, confirm the items marked
# "REVIEW:" below (cost interpretation, units, electrolyser input coupling).

import time
from   pyomo.environ  import Var, Constraint, Binary, UnitInterval, NonNegativeReals
from  .utils.oM_Utils import log_time


[docs] def create_investment(model, optmodel, indlog): """Add the capacity-sizing (investment) layer to the model. Candidate units are those with a positive investment cost. In this model the generator sets already include storage, so the candidates are collected in ``egc`` (electricity, includes BESS and fuel cells) and ``hgc`` (hydrogen, includes electrolysers and hydrogen storage); ``egsc`` and ``hgsc`` are the storage subsets. For each candidate the model chooses a build fraction in ``[0, 1]`` (or a binary build decision); the usable capacity is the nameplate capacity times that fraction. The annualized build cost enters the objective through ``vTotalICost``. This function is additive: it introduces new variables and constraints plus a single extra term in the objective. It does not modify any existing operating constraint. Run it after ``create_variables`` and before ``create_objective_function`` so that ``vTotalICost`` exists when the total system cost is assembled. """ StartTime = time.time() print('-- Declaring investment (capacity-sizing) layer') # Always create the total investment-cost variable so the objective can use # it even when there are no candidate units. setattr(optmodel, 'vTotalICost', Var(within=NonNegativeReals, doc='total annualized investment cost [MEUR]')) if not len(model.egc) and not len(model.hgc): optmodel.vTotalICost.fix(0.0) log_time('--- Declaring the investment layer (no candidate units):', StartTime, ind_log=indlog) return model # %% Build-decision variables (fraction of nameplate built, in [0, 1]) setattr(optmodel, 'vEleGenInvest', Var(model.egc, within=UnitInterval, doc='electricity candidate build fraction [0,1]')) setattr(optmodel, 'vHydGenInvest', Var(model.hgc, within=UnitInterval, doc='hydrogen candidate build fraction [0,1]')) # Make the decision binary (all-or-nothing build) for units flagged for it. for egc in model.egc: try: if model.Par['pEleGenBinaryInvestment'][egc] == 1: optmodel.vEleGenInvest[egc].domain = Binary except (KeyError, TypeError): pass for hgc in model.hgc: try: if model.Par['pHydGenBinaryInvestment'][hgc] == 1: optmodel.vHydGenInvest[hgc].domain = Binary except (KeyError, TypeError): pass # Optional lower/upper bounds on the build fraction, if provided in the data. for egc in model.egc: try: optmodel.vEleGenInvest[egc].setlb(model.Par['pEleGenInvestmentLo'][egc]) except (KeyError, TypeError): pass try: optmodel.vEleGenInvest[egc].setub(model.Par['pEleGenInvestmentUp'][egc]) except (KeyError, TypeError): pass for hgc in model.hgc: try: optmodel.vHydGenInvest[hgc].setlb(model.Par['pHydGenInvestmentLo'][hgc]) except (KeyError, TypeError): pass try: optmodel.vHydGenInvest[hgc].setub(model.Par['pHydGenInvestmentUp'][hgc]) except (KeyError, TypeError): pass # %% Capacity coupling: an unbuilt candidate has zero usable capacity. # These are extra caps on top of the existing nameplate bounds, so they are # purely additive (no existing constraint is changed). With a build fraction # of 1 the existing nameplate bound binds; with 0 the unit is forced to zero. psnegc = [(p, sc, n, egc ) for (p, sc, n) in model.psn for egc in model.egc ] psnegsc = [(p, sc, n, egsc) for (p, sc, n) in model.psn for egsc in model.egsc] psnhgc = [(p, sc, n, hgc ) for (p, sc, n) in model.psn for hgc in model.hgc ] psnhgsc = [(p, sc, n, hgsc) for (p, sc, n) in model.psn for hgsc in model.hgsc] # Electricity candidate output (generators, fuel cells, storage discharge). def eEleInvestMaxOutput(optmodel, p, sc, n, egc): return optmodel.vEleTotalOutput[p, sc, n, egc] <= model.Par['pEleMaxPower'][egc][p, sc, n] * optmodel.vEleGenInvest[egc] optmodel.__setattr__('eEleInvestMaxOutput', Constraint(psnegc, rule=eEleInvestMaxOutput, doc='candidate electricity output limited by build decision')) # Electricity candidate storage: charge power and stored energy. def eEleInvestMaxCharge(optmodel, p, sc, n, egsc): return optmodel.vEleTotalCharge[p, sc, n, egsc] <= model.Par['pEleMaxCharge'][egsc][p, sc, n] * optmodel.vEleGenInvest[egsc] optmodel.__setattr__('eEleInvestMaxCharge', Constraint(psnegsc, rule=eEleInvestMaxCharge, doc='candidate electricity charge limited by build decision')) def eEleInvestMaxInventory(optmodel, p, sc, n, egsc): return optmodel.vEleInventory[p, sc, n, egsc] <= model.Par['pEleMaxStorage'][egsc][p, sc, n] * model.factor1 * optmodel.vEleGenInvest[egsc] optmodel.__setattr__('eEleInvestMaxInventory', Constraint(psnegsc, rule=eEleInvestMaxInventory, doc='candidate electricity storage energy limited by build decision')) # Hydrogen candidate output (electrolysers, hydrogen generators, storage discharge). def eHydInvestMaxOutput(optmodel, p, sc, n, hgc): return optmodel.vHydTotalOutput[p, sc, n, hgc] <= model.Par['pHydMaxPower'][hgc][p, sc, n] * optmodel.vHydGenInvest[hgc] optmodel.__setattr__('eHydInvestMaxOutput', Constraint(psnhgc, rule=eHydInvestMaxOutput, doc='candidate hydrogen output limited by build decision')) # Hydrogen candidate storage: stored energy. def eHydInvestMaxInventory(optmodel, p, sc, n, hgsc): return optmodel.vHydInventory[p, sc, n, hgsc] <= model.Par['pHydMaxStorage'][hgsc][p, sc, n] * optmodel.vHydGenInvest[hgsc] optmodel.__setattr__('eHydInvestMaxInventory', Constraint(psnhgsc, rule=eHydInvestMaxInventory, doc='candidate hydrogen storage energy limited by build decision')) # REVIEW: electrolysers (e2h) and fuel cells (h2e) are sized here only through # their output cap above. If the design variable should be the electrolyser's # ELECTRICITY input capacity, add a cap on vEleTotalCharge[p,sc,n,e2h] # analogous to eEleInvestMaxCharge, indexed over the candidate e2h units. # %% Total investment cost # Unit scaling: model.factor1 is the conversion factor that lets the model work # at either utility (MWh) or local/home (kWh) scale. It is applied to the # capacities (MaximumPower, MaximumCharge, ...) and to the per-energy operating # costs. The investment cost pays for capacity whose operation scales with # factor1, so it is scaled by factor1 too. This keeps the build-versus-operate # trade-off invariant under the unit choice, and means FixedInvestmentCost is # entered in the same native units as the rest of the data. # pEleGenInvestCost / pHydGenInvestCost are the annualized fixed cost of the # FULL nameplate unit (FixedInvestmentCost * FixedChargeRate), so with a build # fraction in [0,1] the cost of a partially built unit is the simple product. # # Period weighting: operating costs enter the objective weighted by # pDiscountFactor[p] per period (eTotalTCost). The annualized investment cost # recurs in every modeled period, so it is weighted by the sum of the period # discount factors. This puts investment on the same discounted, period-weighted # footing as operation, matching the openTEPES treatment, so the build/operate # trade-off is consistent for any modeled horizon. period_weight = sum(model.Par['pDiscountFactor'][p] for p in model.p) def eTotalICost(optmodel): return optmodel.vTotalICost == period_weight * model.factor1 * ( sum(model.Par['pEleGenInvestCost'][egc] * optmodel.vEleGenInvest[egc] for egc in model.egc) + sum(model.Par['pHydGenInvestCost'][hgc] * optmodel.vHydGenInvest[hgc] for hgc in model.hgc)) optmodel.__setattr__('eTotalICost', Constraint(rule=eTotalICost, doc='total period-weighted investment cost')) log_time('--- Declaring the investment (capacity-sizing) layer:', StartTime, ind_log=indlog) return model