def define_components(mod): """ Adds components to a Pyomo abstract model object to describe generation and storage projects. Unless otherwise stated, all power capacity is specified in units of MW and all sets and parameters are mandatory. GENERATION_PROJECTS is the set of generation and storage projects that have been built or could potentially be built. A project is a combination of generation technology, load zone and location. A particular build-out of a project should also include the year in which construction was complete and additional capacity came online. Members of this set are abbreviated as gen in parameter names and g in indexes. Use of p instead of g is discouraged because p is reserved for period. gen_dbid[g] is an external database id for each generation project. This is an optional parameter than defaults to the project index. gen_tech[g] describes what kind of technology a generation project is using. gen_load_zone[g] is the load zone this generation project is built in. VARIABLE_GENS is a subset of GENERATION_PROJECTS that only includes variable generators such as wind or solar that have exogenous constraints on their energy production. BASELOAD_GENS is a subset of GENERATION_PROJECTS that only includes baseload generators such as coal or geothermal. GENS_IN_ZONE[z in LOAD_ZONES] is an indexed set that lists all generation projects within each load zone. CAPACITY_LIMITED_GENS is the subset of GENERATION_PROJECTS that are capacity limited. Most of these will be generator types that are resource limited like wind, solar or geothermal, but this can be specified for any generation project. Some existing or proposed generation projects may have upper bounds on increasing capacity or replacing capacity as it is retired based on permits or local air quality regulations. gen_capacity_limit_mw[g] is defined for generation technologies that are resource limited and do not compete for land area. This describes the maximum possible capacity of a generation project in units of megawatts. -- CONSTRUCTION -- GEN_BLD_YRS is a two-dimensional set of generation projects and the years in which construction or expansion occured or can occur. You can think of a project as a physical site that can be built out over time. BuildYear is the year in which construction is completed and new capacity comes online, not the year when constrution begins. BuildYear will be in the past for existing projects and will be the first year of an investment period for new projects. Investment decisions are made for each project/invest period combination. This set is derived from other parameters for all new construction. This set also includes entries for existing projects that have already been built and planned projects whose capacity buildouts have already been decided; information for legacy projects come from other files and their build years will usually not correspond to the set of investment periods. There are two recommended options for abbreviating this set for denoting indexes: typically this should be written out as (g, build_year) for clarity, but when brevity is more important (g, b) is acceptable. NEW_GEN_BLD_YRS is a subset of GEN_BLD_YRS that only includes projects that have not yet been constructed. This is derived by joining the set of GENERATION_PROJECTS with the set of NEW_GENERATION_BUILDYEARS using generation technology. PREDETERMINED_GEN_BLD_YRS is a subset of GEN_BLD_YRS that only includes existing or planned projects that are not subject to optimization. gen_predetermined_cap[(g, build_year) in PREDETERMINED_GEN_BLD_YRS] is a parameter that describes how much capacity was built in the past for existing projects, or is planned to be built for future projects. BuildGen[g, build_year] is a decision variable that describes how much capacity of a project to install in a given period. This also stores the amount of capacity that was installed in existing projects that are still online. GenCapacity[g, period] is an expression that returns the total capacity online in a given period. This is the sum of installed capacity minus all retirements. Max_Build_Potential[g] is a constraint defined for each project that enforces maximum capacity limits for resource-limited projects. GenCapacity <= gen_capacity_limit_mw NEW_GEN_WITH_MIN_BUILD_YEARS is the subset of NEW_GEN_BLD_YRS for which minimum capacity build-out constraints will be enforced. BuildMinGenCap[g, build_year] is a binary variable that indicates whether a project will build capacity in a period or not. If the model is committing to building capacity, then the minimum must be enforced. Enforce_Min_Build_Lower[g, build_year] and Enforce_Min_Build_Upper[g, build_year] are a pair of constraints that force project build-outs to meet the minimum build requirements for generation technologies that have those requirements. They force BuildGen to be 0 when BuildMinGenCap is 0, and to be greater than g_min_build_capacity when BuildMinGenCap is 1. In the latter case, the upper constraint should be non-binding; the upper limit is set to 10 times the peak non-conincident demand of the entire system. --- OPERATIONS --- PERIODS_FOR_GEN_BLD_YR[g, build_year] is an indexed set that describes which periods a given project build will be operational. BLD_YRS_FOR_GEN_PERIOD[g, period] is a complementary indexed set that identify which build years will still be online for the given project in the given period. For some project-period combinations, this will be an empty set. GEN_PERIODS describes periods in which generation projects could be operational. Unlike the related sets above, it is not indexed. Instead it is specified as a set of (g, period) combinations useful for indexing other model components. --- COSTS --- gen_connect_cost_per_mw[g] is the cost of grid upgrades to support a new project, in dollars per peak MW. These costs include new transmission lines to a substation, substation upgrades and any other grid upgrades that are needed to deliver power from the interconnect point to the load center or from the load center to the broader transmission network. The following cost components are defined for each project and build year. These parameters will always be available, but will typically be populated by the generic costs specified in generator costs inputs file and the load zone cost adjustment multipliers from load_zones inputs file. gen_overnight_cost[g, build_year] is the overnight capital cost per MW of capacity for building a project in the given period. By "installed in the given period", I mean that it comes online at the beginning of the given period and construction starts before that. gen_fixed_om[g, build_year] is the annual fixed Operations and Maintenance costs (O&M) per MW of capacity for given project that was installed in the given period. -- Derived cost parameters -- gen_capital_cost_annual[g, build_year] is the annualized loan payments for a project's capital and connection costs in units of $/MW per year. This is specified in non-discounted real dollars in a future period, not real dollars in net present value. Proj_Fixed_Costs_Annual[g, period] is the total annual fixed costs (capital as well as fixed operations & maintenance) incurred by a project in a period. This reflects all of the builds are operational in the given period. This is an expression that reflect decision variables. ProjFixedCosts[period] is the sum of Proj_Fixed_Costs_Annual[g, period] for all projects that could be online in the target period. This aggregation is performed for the benefit of the objective function. TODO: - Allow early capacity retirements with savings on fixed O&M """ mod.GENERATION_PROJECTS = Set() mod.gen_dbid = Param(mod.GENERATION_PROJECTS, default=lambda m, g: g) mod.gen_tech = Param(mod.GENERATION_PROJECTS) mod.GENERATION_TECHNOLOGIES = Set( initialize=lambda m: {m.gen_tech[g] for g in m.GENERATION_PROJECTS}) mod.gen_energy_source = Param(mod.GENERATION_PROJECTS, validate=lambda m, val, g: val in m. ENERGY_SOURCES or val == "multiple") mod.gen_load_zone = Param(mod.GENERATION_PROJECTS, within=mod.LOAD_ZONES) mod.gen_max_age = Param(mod.GENERATION_PROJECTS, within=PositiveIntegers) mod.gen_is_variable = Param(mod.GENERATION_PROJECTS, within=Boolean) mod.gen_is_baseload = Param(mod.GENERATION_PROJECTS, within=Boolean) mod.gen_is_cogen = Param(mod.GENERATION_PROJECTS, within=Boolean, default=False) mod.gen_is_distributed = Param(mod.GENERATION_PROJECTS, within=Boolean, default=False) mod.gen_scheduled_outage_rate = Param(mod.GENERATION_PROJECTS, within=PercentFraction, default=0) mod.gen_forced_outage_rate = Param(mod.GENERATION_PROJECTS, within=PercentFraction, default=0) mod.min_data_check('GENERATION_PROJECTS', 'gen_tech', 'gen_energy_source', 'gen_load_zone', 'gen_max_age', 'gen_is_variable', 'gen_is_baseload') mod.GENS_IN_ZONE = Set( mod.LOAD_ZONES, initialize=lambda m, z: set(g for g in m.GENERATION_PROJECTS if m.gen_load_zone[g] == z)) mod.VARIABLE_GENS = Set(initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_is_variable[g]) mod.VARIABLE_GENS_IN_ZONE = Set( mod.LOAD_ZONES, initialize=lambda m, z: [g for g in m.GENS_IN_ZONE[z] if m.gen_is_variable[g]]) mod.BASELOAD_GENS = Set(initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_is_baseload[g]) # TODO: use a construction dictionary or closure to create all the GENS_BY_... # indexed sets more efficiently mod.GENS_BY_TECHNOLOGY = Set( mod.GENERATION_TECHNOLOGIES, initialize=lambda m, t: [g for g in m.GENERATION_PROJECTS if m.gen_tech[g] == t]) mod.CAPACITY_LIMITED_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_capacity_limit_mw = Param(mod.CAPACITY_LIMITED_GENS, within=PositiveReals) mod.DISCRETELY_SIZED_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_unit_size = Param(mod.DISCRETELY_SIZED_GENS, within=PositiveReals) mod.CCS_EQUIPPED_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_ccs_capture_efficiency = Param(mod.CCS_EQUIPPED_GENS, within=PercentFraction) mod.gen_ccs_energy_load = Param(mod.CCS_EQUIPPED_GENS, within=PercentFraction) mod.gen_uses_fuel = Param(mod.GENERATION_PROJECTS, initialize=lambda m, g: (m.gen_energy_source[g] in m.FUELS or m. gen_energy_source[g] == "multiple")) mod.NON_FUEL_BASED_GENS = Set(initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: not m.gen_uses_fuel[g]) mod.FUEL_BASED_GENS = Set(initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_uses_fuel[g]) mod.gen_full_load_heat_rate = Param(mod.FUEL_BASED_GENS, within=NonNegativeReals) mod.MULTIFUEL_GENS = Set( initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_energy_source[g] == "multiple") mod.FUELS_FOR_MULTIFUEL_GEN = Set(mod.MULTIFUEL_GENS, within=mod.FUELS) mod.FUELS_FOR_GEN = Set( mod.FUEL_BASED_GENS, initialize=lambda m, g: (m.FUELS_FOR_MULTIFUEL_GEN[g] if g in m.MULTIFUEL_GENS else [m.gen_energy_source[g]])) mod.GENS_BY_NON_FUEL_ENERGY_SOURCE = Set( mod.NON_FUEL_ENERGY_SOURCES, initialize=lambda m, s: [g for g in m.NON_FUEL_BASED_GENS if m.gen_energy_source[g] == s]) mod.GENS_BY_FUEL = Set( mod.FUELS, initialize=lambda m, f: [g for g in m.FUEL_BASED_GENS if f in m.FUELS_FOR_GEN[g]]) mod.PREDETERMINED_GEN_BLD_YRS = Set(dimen=2) mod.GEN_BLD_YRS = Set(dimen=2, validate=lambda m, g, bld_yr: ((g, bld_yr) in m.PREDETERMINED_GEN_BLD_YRS or (g, bld_yr) in m.GENERATION_PROJECTS * m.PERIODS)) mod.NEW_GEN_BLD_YRS = Set( dimen=2, initialize=lambda m: m.GEN_BLD_YRS - m.PREDETERMINED_GEN_BLD_YRS) mod.gen_predetermined_cap = Param(mod.PREDETERMINED_GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_predetermined_cap') def _gen_build_can_operate_in_period(m, g, build_year, period): if build_year in m.PERIODS: online = m.period_start[build_year] else: online = build_year retirement = online + m.gen_max_age[g] return (online <= m.period_start[period] < retirement) # This is probably more correct, but is a different behavior # mid_period = m.period_start[period] + 0.5 * m.period_length_years[period] # return online <= m.period_start[period] and mid_period <= retirement # The set of periods when a project built in a certain year will be online mod.PERIODS_FOR_GEN_BLD_YR = Set( mod.GEN_BLD_YRS, within=mod.PERIODS, ordered=True, initialize=lambda m, g, bld_yr: set( period for period in m.PERIODS if _gen_build_can_operate_in_period(m, g, bld_yr, period))) # The set of build years that could be online in the given period # for the given project. mod.BLD_YRS_FOR_GEN_PERIOD = Set( mod.GENERATION_PROJECTS, mod.PERIODS, initialize=lambda m, g, period: set( bld_yr for (gen, bld_yr) in m.GEN_BLD_YRS if gen == g and _gen_build_can_operate_in_period( m, g, bld_yr, period))) # The set of periods when a generator is available to run mod.PERIODS_FOR_GEN = Set( mod.GENERATION_PROJECTS, initialize=lambda m, g: [p for p in m.PERIODS if len(m.BLD_YRS_FOR_GEN_PERIOD[g, p]) > 0]) def bounds_BuildGen(model, g, bld_yr): if ((g, bld_yr) in model.PREDETERMINED_GEN_BLD_YRS): return (model.gen_predetermined_cap[g, bld_yr], model.gen_predetermined_cap[g, bld_yr]) elif (g in model.CAPACITY_LIMITED_GENS): # This does not replace Max_Build_Potential because # Max_Build_Potential applies across all build years. return (0, model.gen_capacity_limit_mw[g]) else: return (0, None) mod.BuildGen = Var(mod.GEN_BLD_YRS, within=NonNegativeReals, bounds=bounds_BuildGen) # Some projects are retired before the first study period, so they # don't appear in the objective function or any constraints. # In this case, pyomo may leave the variable value undefined even # after a solve, instead of assigning a value within the allowed # range. This causes errors in the Progressive Hedging code, which # expects every variable to have a value after the solve. So as a # starting point we assign an appropriate value to all the existing # projects here. def BuildGen_assign_default_value(m, g, bld_yr): m.BuildGen[g, bld_yr] = m.gen_predetermined_cap[g, bld_yr] mod.BuildGen_assign_default_value = BuildAction( mod.PREDETERMINED_GEN_BLD_YRS, rule=BuildGen_assign_default_value) # note: in pull request 78, commit e7f870d..., GEN_PERIODS # was mistakenly redefined as GENERATION_PROJECTS * PERIODS. # That didn't directly affect the objective function in the tests # because most code uses GEN_TPS, which was defined correctly. # But it did have some subtle effects on the main Hawaii model. # It would be good to have a test that this set is correct, # e.g., assertions that in the 3zone_toy model, # ('C-Coal_ST', 2020) in m.GEN_PERIODS and ('C-Coal_ST', 2030) not in m.GEN_PERIODS # and 'C-Coal_ST' in m.GENS_IN_PERIOD[2020] and 'C-Coal_ST' not in m.GENS_IN_PERIOD[2030] mod.GEN_PERIODS = Set(dimen=2, initialize=lambda m: [(g, p) for g in m.GENERATION_PROJECTS for p in m.PERIODS_FOR_GEN[g]]) mod.GenCapacity = Expression( mod.GENERATION_PROJECTS, mod.PERIODS, rule=lambda m, g, period: sum(m.BuildGen[g, bld_yr] for bld_yr in m. BLD_YRS_FOR_GEN_PERIOD[g, period])) mod.Max_Build_Potential = Constraint( mod.CAPACITY_LIMITED_GENS, mod.PERIODS, rule=lambda m, g, p: (m.gen_capacity_limit_mw[g] >= m.GenCapacity[g, p])) # The following components enforce minimum capacity build-outs. # Note that this adds binary variables to the model. mod.gen_min_build_capacity = Param(mod.GENERATION_PROJECTS, within=NonNegativeReals, default=0) mod.NEW_GEN_WITH_MIN_BUILD_YEARS = Set(initialize=mod.NEW_GEN_BLD_YRS, filter=lambda m, g, p: (m.gen_min_build_capacity[g] > 0)) mod.BuildMinGenCap = Var(mod.NEW_GEN_WITH_MIN_BUILD_YEARS, within=Binary) mod.Enforce_Min_Build_Lower = Constraint( mod.NEW_GEN_WITH_MIN_BUILD_YEARS, rule=lambda m, g, p: (m.BuildMinGenCap[g, p] * m. gen_min_build_capacity[g] <= m.BuildGen[g, p])) # Define a constant for enforcing binary constraints on project capacity # The value of 100 GW should be larger than any expected build size. For # perspective, the world's largest electric power plant (Three Gorges Dam) # is 22.5 GW. I tried using 1 TW, but CBC had numerical stability problems # with that value and chose a suboptimal solution for the # discrete_and_min_build example which is installing capacity of 3-5 MW. mod._gen_max_cap_for_binary_constraints = 10**5 mod.Enforce_Min_Build_Upper = Constraint( mod.NEW_GEN_WITH_MIN_BUILD_YEARS, rule=lambda m, g, p: (m.BuildGen[g, p] <= m.BuildMinGenCap[g, p] * mod. _gen_max_cap_for_binary_constraints)) # Costs mod.gen_variable_om = Param(mod.GENERATION_PROJECTS, within=NonNegativeReals) mod.gen_connect_cost_per_mw = Param(mod.GENERATION_PROJECTS, within=NonNegativeReals) mod.min_data_check('gen_variable_om', 'gen_connect_cost_per_mw') mod.gen_overnight_cost = Param(mod.GEN_BLD_YRS, within=NonNegativeReals) mod.gen_fixed_om = Param(mod.GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_overnight_cost', 'gen_fixed_om') # Derived annual costs mod.gen_capital_cost_annual = Param( mod.GEN_BLD_YRS, initialize=lambda m, g, bld_yr: ( (m.gen_overnight_cost[g, bld_yr] + m.gen_connect_cost_per_mw[g] ) * crf(m.interest_rate, m.gen_max_age[g]))) mod.GenCapitalCosts = Expression( mod.GENERATION_PROJECTS, mod.PERIODS, rule=lambda m, g, p: sum(m.BuildGen[g, bld_yr] * m. gen_capital_cost_annual[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, p])) mod.GenFixedOMCosts = Expression( mod.GENERATION_PROJECTS, mod.PERIODS, rule=lambda m, g, p: sum(m.BuildGen[g, bld_yr] * m.gen_fixed_om[ g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, p])) # Summarize costs for the objective function. Units should be total # annual future costs in $base_year real dollars. The objective # function will convert these to base_year Net Present Value in # $base_year real dollars. mod.TotalGenFixedCosts = Expression( mod.PERIODS, rule=lambda m, p: sum(m.GenCapitalCosts[g, p] + m.GenFixedOMCosts[g, p] for g in m.GENERATION_PROJECTS)) mod.Cost_Components_Per_Period.append('TotalGenFixedCosts')
def define_components(mod): """ STORAGE_GENS is the subset of projects that can provide energy storage. STORAGE_GEN_BLD_YRS is the subset of GEN_BLD_YRS, restricted to storage projects. gen_storage_efficiency[STORAGE_GENS] describes the round trip efficiency of a storage technology. A storage technology that is 75 percent efficient would have a storage_efficiency of .75. If 1 MWh was stored in such a storage project, 750 kWh would be available for extraction later. Internal leakage or energy dissipation of storage technologies is assumed to be neglible, which is consistent with short-duration storage technologies currently on the market which tend to consume stored power within 1 day. If a given storage technology has significant internal discharge when it stores power for extended time perios, then those behaviors will need to be modeled in more detail. gen_store_to_release_ratio[STORAGE_GENS] describes the maximum rate that energy can be stored, expressed as a ratio of discharge power capacity. This is an optional parameter and will default to 1. If a storage project has 1 MW of dischage capacity and a gen_store_to_release_ratio of 1.2, then it can consume up to 1.2 MW of power while charging. gen_storage_energy_to_power_ratio[STORAGE_GENS], if specified, restricts the storage capacity (in MWh) to be a fixed multiple of the output power (in MW), i.e., specifies a particular number of hours of storage capacity. Omit this column or specify "." to allow Switch to choose the energy/power ratio. (Note: gen_storage_energy_overnight_cost or gen_overnight_cost should often be set to 0 when using this.) gen_storage_max_cycles_per_year[STORAGE_GENS], if specified, restricts the number of charge/discharge cycles each storage project can perform per year; one cycle is defined as discharging an amount of energy equal to the storage capacity of the project. gen_storage_energy_overnight_cost[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is the overnight capital cost per MWh of energy capacity for building the given storage technology installed in the given investment period. This is only defined for storage technologies. Note that this describes the energy component and the overnight_cost describes the power component. BuildStorageEnergy[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is a decision of how much energy capacity to build onto a storage project. This is analogous to BuildGen, but for energy rather than power. StorageEnergyInstallCosts[PERIODS] is an expression of the annual costs incurred by the BuildStorageEnergy decision. StorageEnergyCapacity[g, period] is an expression describing the cumulative available energy capacity of BuildStorageEnergy. This is analogous to GenCapacity. STORAGE_GEN_TPS is the subset of GEN_TPS, restricted to storage projects. ChargeStorage[(g, t) in STORAGE_GEN_TPS] is a dispatch decision of how much to charge a storage project in each timepoint. StorageNetCharge[LOAD_ZONE, TIMEPOINT] is an expression describing the aggregate impact of ChargeStorage in each load zone and timepoint. Charge_Storage_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains ChargeStorage to available power capacity (accounting for gen_store_to_release_ratio) StateOfCharge[(g, t) in STORAGE_GEN_TPS] is a variable for tracking state of charge. This value stores the state of charge at the end of each timepoint for each storage project. Track_State_Of_Charge[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on the StateOfCharge in the previous timepoint, ChargeStorage and DispatchGen. State_Of_Charge_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on installed energy capacity. """ mod.STORAGE_GENS = Set(within=mod.GENERATION_PROJECTS) mod.STORAGE_GEN_PERIODS = Set( within=mod.GEN_PERIODS, initialize=lambda m: [(g, p) for g in m.STORAGE_GENS for p in m.PERIODS_FOR_GEN[g]] ) mod.gen_storage_efficiency = Param( mod.STORAGE_GENS, within=PercentFraction) # TODO: rename to gen_charge_to_discharge_ratio? mod.gen_store_to_release_ratio = Param( mod.STORAGE_GENS, within=PositiveReals, default=1.0) mod.gen_storage_energy_to_power_ratio = Param( mod.STORAGE_GENS, within=NonNegativeReals, default=float("inf")) # inf is a flag that no value is specified (nan and None don't work) mod.gen_storage_max_cycles_per_year = Param( mod.STORAGE_GENS, within=NonNegativeReals, default=float('inf')) # TODO: build this set up instead of filtering down, to improve performance mod.STORAGE_GEN_BLD_YRS = Set( dimen=2, initialize=mod.GEN_BLD_YRS, filter=lambda m, g, bld_yr: g in m.STORAGE_GENS) mod.gen_storage_energy_overnight_cost = Param( mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_storage_energy_overnight_cost') mod.BuildStorageEnergy = Var( mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) # Summarize capital costs of energy storage for the objective function. mod.StorageEnergyInstallCosts = Expression( mod.PERIODS, rule=lambda m, p: sum(m.BuildStorageEnergy[g, bld_yr] * m.gen_storage_energy_overnight_cost[g, bld_yr] * crf(m.interest_rate, m.gen_max_age[g]) for (g, bld_yr) in m.STORAGE_GEN_BLD_YRS)) mod.Cost_Components_Per_Period.append( 'StorageEnergyInstallCosts') mod.StorageEnergyCapacity = Expression( mod.STORAGE_GENS, mod.PERIODS, rule=lambda m, g, period: sum( m.BuildStorageEnergy[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, period])) mod.STORAGE_GEN_TPS = Set( dimen=2, initialize=lambda m: ( (g, tp) for g in m.STORAGE_GENS for tp in m.TPS_FOR_GEN[g])) mod.ChargeStorage = Var( mod.STORAGE_GEN_TPS, within=NonNegativeReals) # Summarize storage charging for the energy balance equations # TODO: rename this StorageTotalCharging or similar (to indicate it's a # sum for a zone, not a net quantity for a project) def rule(m, z, t): # Construct and cache a set for summation as needed if not hasattr(m, 'Storage_Charge_Summation_dict'): m.Storage_Charge_Summation_dict = collections.defaultdict(set) for g, t2 in m.STORAGE_GEN_TPS: z2 = m.gen_load_zone[g] m.Storage_Charge_Summation_dict[z2, t2].add(g) # Use pop to free memory relevant_projects = m.Storage_Charge_Summation_dict.pop((z, t), {}) return sum(m.ChargeStorage[g, t] for g in relevant_projects) mod.StorageNetCharge = Expression(mod.LOAD_ZONES, mod.TIMEPOINTS, rule=rule) # Register net charging with zonal energy balance. Discharging is already # covered by DispatchGen. mod.Zone_Power_Withdrawals.append('StorageNetCharge') # use fixed energy/power ratio (# hours of capacity) when specified mod.Enforce_Fixed_Energy_Storage_Ratio = Constraint( mod.STORAGE_GEN_BLD_YRS, rule=lambda m, g, y: Constraint.Skip if m.gen_storage_energy_to_power_ratio[g] == float("inf") # no value specified else (m.BuildStorageEnergy[g, y] == m.gen_storage_energy_to_power_ratio[g] * m.BuildGen[g, y]) ) def Charge_Storage_Upper_Limit_rule(m, g, t): return m.ChargeStorage[g,t] <= \ m.DispatchUpperLimit[g, t] * m.gen_store_to_release_ratio[g] mod.Charge_Storage_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=Charge_Storage_Upper_Limit_rule) mod.StateOfCharge = Var( mod.STORAGE_GEN_TPS, within=NonNegativeReals) def Track_State_Of_Charge_rule(m, g, t): return m.StateOfCharge[g, t] == \ m.StateOfCharge[g, m.tp_previous[t]] + \ (m.ChargeStorage[g, t] * m.gen_storage_efficiency[g] - m.DispatchGen[g, t]) * m.tp_duration_hrs[t] mod.Track_State_Of_Charge = Constraint( mod.STORAGE_GEN_TPS, rule=Track_State_Of_Charge_rule) def State_Of_Charge_Upper_Limit_rule(m, g, t): return m.StateOfCharge[g, t] <= \ m.StorageEnergyCapacity[g, m.tp_period[t]] mod.State_Of_Charge_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=State_Of_Charge_Upper_Limit_rule) # batteries can only complete the specified number of cycles per year, averaged over each period mod.Battery_Cycle_Limit = Constraint( mod.STORAGE_GEN_PERIODS, rule=lambda m, g, p: # solvers sometimes perform badly with infinite constraint Constraint.Skip if m.gen_storage_max_cycles_per_year[g] == float('inf') else ( sum(m.DispatchGen[g, tp] * m.tp_duration_hrs[tp] for tp in m.TPS_IN_PERIOD[p]) <= m.gen_storage_max_cycles_per_year[g] * m.StorageEnergyCapacity[g, p] * m.period_length_years[p] ) )
def post_solve(m, outdir=None): """ Calculate detailed costs per generation project per period. """ if outdir is None: outdir = m.options.outputs_dir zone_fuel_cost = get_zone_fuel_cost(m) has_subsidies = hasattr(m, 'gen_investment_subsidy_fraction') gen_data = OrderedDict() gen_period_data = OrderedDict() gen_vintage_period_data = OrderedDict() for g, p in sorted(m.GEN_PERIODS): # helper function to calculate annual sums def ann(expr): try: return sum( expr(g, t) * m.tp_weight_in_year[t] for t in m.TPS_IN_PERIOD[p]) except AttributeError: # expression uses a component that doesn't exist return None # is this a storage gen? is_storage = hasattr(m, 'STORAGE_GENS') and g in m.STORAGE_GENS BuildGen = m.BuildGen[g, p] if (g, p) in m.GEN_BLD_YRS else 0.0 # BuildStorageEnergy = ( # m.BuildStorageEnergy[g, p] # if is_storage and (g, p) in m.GEN_BLD_YRS # else 0.0 # ) gen_data[g] = OrderedDict(gen_tech=m.gen_tech[g], gen_load_zone=m.gen_load_zone[g], gen_energy_source=m.gen_energy_source[g], gen_is_intermittent=int( m.gen_is_variable[g])) # temporary storage of per-generator data to be allocated per-vintage # below gen_period_data = OrderedDict( total_output=0.0 if is_storage else ann(lambda g, t: m.DispatchGen[g, t]), renewable_output=0.0 if is_storage else ann(lambda g, t: renewable_mw(m, g, t)), non_renewable_output=0.0 if is_storage else ann(lambda g, t: m.DispatchGen[g, t] - renewable_mw(m, g, t)), storage_load=( ann(lambda g, t: m.ChargeStorage[g, t] - m.DispatchGen[g, t]) if is_storage else 0.0), fixed_om=m.GenFixedOMCosts[g, p], variable_om=ann( lambda g, t: m.DispatchGen[g, t] * m.gen_variable_om[g]), startup_om=ann(lambda g, t: m.gen_startup_om[g] * m. StartupGenCapacity[g, t] / m.tp_duration_hrs[t]), fuel_cost=ann(lambda g, t: sum( 0.0 # avoid nan fuel prices for unused fuels if m.GenFuelUseRate[g, t, f] == 0.0 else (m.GenFuelUseRate[g, t, f] * zone_fuel_cost[m.gen_load_zone[ g], f, m.tp_period[t]]) for f in m.FUELS_FOR_GEN[g]) if g in m.FUEL_BASED_GENS else 0.0)) for v in m.BLD_YRS_FOR_GEN_PERIOD[g, p]: # fill in data for each vintage of generator that is active now gen_vintage_period_data[g, v, p] = OrderedDict( capacity_in_place=m.BuildGen[g, v], capacity_added=m.BuildGen[g, p] if p == v else 0.0, capital_outlay=(m.BuildGen[g, p] * (m.gen_overnight_cost[g, p] + m.gen_connect_cost_per_mw[g]) * ((1.0 - m.gen_investment_subsidy_fraction[g, p] ) if has_subsidies else 1.0) + ((m.BuildStorageEnergy[g, p] * m.gen_storage_energy_overnight_cost[g, p]) if is_storage else 0.0)) if p == v else 0.0, amortized_cost=m.BuildGen[g, v] * m.gen_capital_cost_annual[g, v] + ((m.BuildStorageEnergy[g, v] * m.gen_storage_energy_overnight_cost[g, v] * crf(m.interest_rate, m.gen_max_age[g])) if is_storage else 0.0) - ((m.gen_investment_subsidy_fraction[g, v] * m.BuildGen[g, v] * m.gen_capital_cost_annual[g, v]) if has_subsidies else 0.0), ) # allocate per-project values among the vintages based on amount # of capacity currently online (may not be physically meaningful if # gens have discrete commitment, but we assume the gens are run # roughly this way) vintage_share = ratio(m.BuildGen[g, v], m.GenCapacity[g, p]) for var, val in gen_period_data.items(): gen_vintage_period_data[g, v, p][var] = vintage_share * val # record capacity retirements # (this could be done earlier if we included the variable name # in the dictionary key tuple instead of having a data dict for # each key) for g, v in m.GEN_BLD_YRS: retire_year = v + m.gen_max_age[g] # find the period when this retires for p in m.PERIODS: if p >= retire_year: gen_vintage_period_data \ .setdefault((g, v, p), OrderedDict())['capacity_retired'] \ = m.BuildGen[g, v] break # convert dicts to data frames generator_df = (pd.DataFrame( evaluate(gen_vintage_period_data)).unstack().to_frame(name='value')) generator_df.index.names = [ 'generation_project', 'gen_vintage', 'period', 'variable' ] for g, d in gen_data.items(): for k, v in d.items(): # assign generator general data to all rows with generator==g generator_df.loc[g, k] = v # convert from float generator_df['gen_is_intermittent'] = generator_df[ 'gen_is_intermittent'].astype(int) generator_df = generator_df.reset_index().set_index([ 'generation_project', 'gen_vintage', 'gen_tech', 'gen_load_zone', 'gen_energy_source', 'gen_is_intermittent', 'variable' ]).sort_index() generator_df.to_csv(os.path.join(outdir, 'generation_project_details.csv'), index=True) # dict should be var, gen, period # but gens have all-years values too (technology, fuel, etc.) # and there are per-year non-gen values # report other costs on an undiscounted, annualized basis # (custom modules, transmission, etc.) # List of comparisons to make later; dict value shows which model # components should match which variables in generator_df itemized_cost_comparisons = { 'gen_fixed_cost': ([ 'TotalGenFixedCosts', 'StorageEnergyFixedCost', 'TotalGenCapitalCostsSubsidy' ], ['amortized_cost', 'fixed_om']), 'fuel_cost': (['FuelCostsPerPeriod', 'RFM_Fixed_Costs_Annual'], ['fuel_cost']), 'variable_om': (['GenVariableOMCostsInTP', 'Total_StartupGenCapacity_OM_Costs'], ['startup_om', 'variable_om']) } ##### most detailed level of data: # owner, tech, generator, fuel (if relevant, otherwise 'all' or specific fuel or 'multiple'?) # then aggregate up """ In generic summarize_results.py: - lists of summary expressions; each creates a new variable per indexing set then those get added to summary tables, which then get aggregated gen_fuel_period_exprs gen_period_exprs (can incl. owner, added to top of list from outside) gen_exprs -> get pushed down into gen_period table? or only when creating by-period summaries? period_exprs (get added as quasi-gens) fuel_period_exprs these create tables like 'summary_per_gen_fuel_period' (including quasi gen data from period_exprs and fuel_period_exprs). Those get pivoted to make 'summary_per_gen_fuel_by_period', with data from 'summary_per_gen_fuel' added to the same rows. Maybe there should be a list of summary groups too. ugh. """ # list of costs that should have already been accounted for itemized_gen_costs = set( component for model_costs, df_costs in itemized_cost_comparisons.values() for component in model_costs) non_gen_costs = OrderedDict() for p in m.PERIODS: non_gen_costs[p] = { cost: getattr(m, cost)[p] for cost in m.Cost_Components_Per_Period if cost not in itemized_gen_costs } for cost in m.Cost_Components_Per_TP: if cost not in itemized_gen_costs: non_gen_costs[p][cost] = sum( getattr(m, cost)[t] * m.tp_weight_in_year[t] for t in m.TPS_IN_PERIOD[p]) non_gen_costs[p]['co2_emissions'] = m.AnnualEmissions[p] non_gen_costs[p]['gross_load'] = ann( lambda g, t: sum(m.zone_demand_mw[z, t] for z in m.LOAD_ZONES)) non_gen_costs[p]['ev_load'] = 0.0 if hasattr(m, 'ChargeEVs'): non_gen_costs[p]['ev_load'] += ann( lambda g, t: sum(m.ChargeEVs[z, t] for z in m.LOAD_ZONES)) if hasattr(m, 'ev_charge_min') and hasattr(m, 'ChargeEVs_min'): m.logger.error( 'ERROR: Need to update {} to handle combined loads from ' 'ev_simple and ev_advanced modules'.format(__name__)) if hasattr(m, 'StorePumpedHydro'): non_gen_costs[p]['Pumped_Hydro_Net_Load'] = ann(lambda g, t: sum( m.StorePumpedHydro[z, t] - m.GeneratePumpedHydro[z, t] for z in m.LOAD_ZONES)) non_gen_df = pd.DataFrame( evaluate(non_gen_costs)).unstack().to_frame(name='value') non_gen_df.index.names = ['period', 'variable'] non_gen_df.to_csv( os.path.join(outdir, 'non_generation_costs_by_period.csv')) # check whether reported generator costs match values used in the model gen_df_totals = generator_df.groupby(['variable', 'period'])['value'].sum() gen_total_costs = defaultdict(float) for label, (model_costs, df_costs) in itemized_cost_comparisons.items(): for p in m.PERIODS: for cost in model_costs: if cost in m.Cost_Components_Per_Period: cost_val = value(getattr(m, cost)[p]) elif cost in m.Cost_Components_Per_TP: # aggregate to period cost_val = value( sum( getattr(m, cost)[t] * m.tp_weight_in_year[t] for t in m.TPS_IN_PERIOD[p])) else: cost_val = 0.0 gen_total_costs[label, p, 'model'] += cost_val gen_total_costs[label, p, 'reported'] = (gen_df_totals.loc[df_costs, p].sum()) mc = gen_total_costs[label, p, 'model'] rc = gen_total_costs[label, p, 'reported'] if different(mc, rc): m.logger.warning( "WARNING: model and reported values don't match for {} in " "{}: {:,.0f} != {:,.0f}; NPV of difference: {:,.0f}.". format(label, p, mc, rc, m.bring_annual_costs_to_base_year[p] * (mc - rc))) raise # else: # m.logger.info( # "INFO: model and reported values match for {} in " # "{}: {} == {}.".format(label, p, mc, rc) # ) # check costs on an aggregated basis too (should be OK if the gen costs are) cost_vars = [ var for model_costs, df_costs in itemized_cost_comparisons.values() for var in df_costs ] total_costs = ( generator_df.loc[pd.IndexSlice[:, :, :, :, cost_vars], :]. groupby('period')['value'].sum()) + non_gen_df.unstack(0).drop( ['co2_emissions', 'gross_load', 'Pumped_Hydro_Net_Load']).sum() npv_cost = value( sum(m.bring_annual_costs_to_base_year[p] * v for ((_, p), v) in total_costs.iteritems())) system_cost = value(m.SystemCost) if different(npv_cost, system_cost): m.logger.warning( "WARNING: NPV of all costs in model doesn't match reported total: " "{:,.0f} != {:,.0f}; difference: {:,.0f}.".format( npv_cost, system_cost, npv_cost - system_cost)) print() print("TODO: *** check for missing MWh terms in {}.".format(__name__)) print() print("Creating RIST summary; may take several minutes.") summarize_for_rist(m, outdir) # data for HECO info request 2/14/20 print("Saving hourly reserve data.") report_hourly_reserves(m) if hasattr(m, 'Smooth_Free_Variables'): # using the smooth_dispatch module; re-report dispatch data print("Re-saving dispatch data after smoothing.") import switch_model.generators.core.dispatch as dispatch dispatch.post_solve(m, m.options.outputs_dir) else: print( "WARNING: the smooth_dispatch module is not being used. Hourly " "dispatch may be rough and hourly contingency reserve targets may " "inflated.") print("Comparing Switch to EIA production data.") if True: compare_switch_to_eia_production(m) else: print("(skipped, takes several minutes)")
def define_components(m): # electrolyzer details m.hydrogen_electrolyzer_capital_cost_per_mw = Param() m.hydrogen_electrolyzer_fixed_cost_per_mw_year = Param(default=0.0) m.hydrogen_electrolyzer_variable_cost_per_kg = Param(default=0.0) # assumed to include any refurbishment needed m.hydrogen_electrolyzer_kg_per_mwh = Param() # assumed to deliver H2 at enough pressure for liquifier and daily buffering m.hydrogen_electrolyzer_life_years = Param() m.BuildElectrolyzerMW = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) m.ElectrolyzerCapacityMW = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildElectrolyzerMW[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS[p])) m.RunElectrolyzerMW = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.ProduceHydrogenKgPerHour = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.RunElectrolyzerMW[z, t] * m.hydrogen_electrolyzer_kg_per_mwh) # note: we assume there is a gaseous hydrogen storage tank that is big enough to buffer # daily production, storage and withdrawals of hydrogen, but we don't include a cost # for this (because it will be negligible compared to the rest of the costs) # This allows the system to do some intra-day arbitrage without going all the way to liquification # liquifier details m.hydrogen_liquifier_capital_cost_per_kg_per_hour = Param() m.hydrogen_liquifier_fixed_cost_per_kg_hour_year = Param(default=0.0) m.hydrogen_liquifier_variable_cost_per_kg = Param(default=0.0) m.hydrogen_liquifier_mwh_per_kg = Param() m.hydrogen_liquifier_life_years = Param() m.BuildLiquifierKgPerHour = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) # capacity to build, measured in kg/hour of throughput m.LiquifierCapacityKgPerHour = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildLiquifierKgPerHour[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS[p])) m.LiquifyHydrogenKgPerHour = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.LiquifyHydrogenMW = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.LiquifyHydrogenKgPerHour[z, t] * m.hydrogen_liquifier_mwh_per_kg ) # storage tank details m.liquid_hydrogen_tank_capital_cost_per_kg = Param() m.liquid_hydrogen_tank_minimum_size_kg = Param(default=0.0) m.liquid_hydrogen_tank_life_years = Param() m.BuildLiquidHydrogenTankKg = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) # in kg m.LiquidHydrogenTankCapacityKg = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildLiquidHydrogenTankKg[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS[p])) m.StoreLiquidHydrogenKg = Expression(m.LOAD_ZONES, m.TIMESERIES, rule=lambda m, z, ts: m.ts_duration_of_tp[ts] * sum(m.LiquifyHydrogenKgPerHour[z, tp] for tp in m.TPS_IN_TS[ts]) ) m.WithdrawLiquidHydrogenKg = Var(m.LOAD_ZONES, m.TIMESERIES, within=NonNegativeReals) # note: we assume the system will be large enough to neglect boil-off # fuel cell details m.hydrogen_fuel_cell_capital_cost_per_mw = Param() m.hydrogen_fuel_cell_fixed_cost_per_mw_year = Param(default=0.0) m.hydrogen_fuel_cell_variable_cost_per_mwh = Param(default=0.0) # assumed to include any refurbishment needed m.hydrogen_fuel_cell_mwh_per_kg = Param() m.hydrogen_fuel_cell_life_years = Param() m.BuildFuelCellMW = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) m.FuelCellCapacityMW = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildFuelCellMW[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS[p])) m.DispatchFuelCellMW = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.ConsumeHydrogenKgPerHour = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.DispatchFuelCellMW[z, t] / m.hydrogen_fuel_cell_mwh_per_kg ) # hydrogen mass balances # note: this allows for buffering of same-day production and consumption # of hydrogen without ever liquifying it m.Hydrogen_Conservation_of_Mass_Daily = Constraint(m.LOAD_ZONES, m.TIMESERIES, rule=lambda m, z, ts: m.StoreLiquidHydrogenKg[z, ts] - m.WithdrawLiquidHydrogenKg[z, ts] == m.ts_duration_of_tp[ts] * sum( m.ProduceHydrogenKgPerHour[z, tp] - m.ConsumeHydrogenKgPerHour[z, tp] for tp in m.TPS_IN_TS[ts] ) ) m.Hydrogen_Conservation_of_Mass_Annual = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum( (m.StoreLiquidHydrogenKg[z, ts] - m.WithdrawLiquidHydrogenKg[z, ts]) * m.ts_scale_to_year[ts] for ts in m.TS_IN_PERIOD[p] ) == 0 ) # limits on equipment m.Max_Run_Electrolyzer = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.RunElectrolyzerMW[z, t] <= m.ElectrolyzerCapacityMW[z, m.tp_period[t]]) m.Max_Run_Fuel_Cell = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.DispatchFuelCellMW[z, t] <= m.FuelCellCapacityMW[z, m.tp_period[t]]) m.Max_Run_Liquifier = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.LiquifyHydrogenKgPerHour[z, t] <= m.LiquifierCapacityKgPerHour[z, m.tp_period[t]]) # minimum size for hydrogen tank m.BuildAnyLiquidHydrogenTank = Var(m.LOAD_ZONES, m.PERIODS, within=Binary) m.Set_BuildAnyLiquidHydrogenTank_Flag = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: Constraint.Skip if m.liquid_hydrogen_tank_minimum_size_kg == 0.0 else ( m.BuildLiquidHydrogenTankKg[z, p] <= 1000 * m.BuildAnyLiquidHydrogenTank[z, p] * m.liquid_hydrogen_tank_minimum_size_kg ) ) m.Build_Minimum_Liquid_Hydrogen_Tank = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: Constraint.Skip if m.liquid_hydrogen_tank_minimum_size_kg == 0.0 else ( m.BuildLiquidHydrogenTankKg[z, p] >= m.BuildAnyLiquidHydrogenTank[z, p] * m.liquid_hydrogen_tank_minimum_size_kg ) ) # maximum amount that hydrogen fuel cells can contribute to system reserves # Note: we assume we can't use fuel cells for reserves unless we've also built at least half # as much electrolyzer capacity and a tank that can provide the reserves for 12 hours # (this is pretty arbitrary, but avoids just installing a fuel cell as a "free" source of reserves) m.HydrogenFuelCellMaxReservePower = Var(m.LOAD_ZONES, m.TIMEPOINTS) m.Hydrogen_FC_Reserve_Capacity_Limit = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.HydrogenFuelCellMaxReservePower[z, t] <= m.FuelCellCapacityMW[z, m.tp_period[t]] ) m.Hydrogen_FC_Reserve_Storage_Limit = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.HydrogenFuelCellMaxReservePower[z, t] <= m.LiquidHydrogenTankCapacityKg[z, m.tp_period[t]] * m.hydrogen_fuel_cell_mwh_per_kg / 12.0 ) m.Hydrogen_FC_Reserve_Electrolyzer_Limit = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.HydrogenFuelCellMaxReservePower[z, t] <= 2.0 * m.ElectrolyzerCapacityMW[z, m.tp_period[t]] ) # how much extra power could hydrogen equipment produce or absorb on short notice (for reserves) m.HydrogenSlackUp = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.RunElectrolyzerMW[z, t] + m.LiquifyHydrogenMW[z, t] + m.HydrogenFuelCellMaxReservePower[z, t] - m.DispatchFuelCellMW[z, t] ) m.HydrogenSlackDown = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.ElectrolyzerCapacityMW[z, m.tp_period[t]] - m.RunElectrolyzerMW[z, t] # ignore liquifier potential since it's small and this is a low-value reserve product + m.DispatchFuelCellMW[z, t] ) # there must be enough storage to hold _all_ the production each period (net of same-day consumption) # note: this assumes we cycle the system only once per year (store all energy, then release all energy) # alternatives: allow monthly or seasonal cycling, or directly model the whole year with inter-day linkages m.Max_Store_Liquid_Hydrogen = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.StoreLiquidHydrogenKg[z, ts] * m.ts_scale_to_year[ts] for ts in m.TS_IN_PERIOD[p]) <= m.LiquidHydrogenTankCapacityKg[z, p] ) # add electricity consumption and production to the zonal energy balance m.Zone_Power_Withdrawals.append('RunElectrolyzerMW') m.Zone_Power_Withdrawals.append('LiquifyHydrogenMW') m.Zone_Power_Injections.append('DispatchFuelCellMW') # add costs to the model m.HydrogenVariableCost = Expression(m.TIMEPOINTS, rule=lambda m, t: sum( m.ProduceHydrogenKgPerHour[z, t] * m.hydrogen_electrolyzer_variable_cost_per_kg + m.LiquifyHydrogenKgPerHour[z, t] * m.hydrogen_liquifier_variable_cost_per_kg + m.DispatchFuelCellMW[z, t] * m.hydrogen_fuel_cell_variable_cost_per_mwh for z in m.LOAD_ZONES ) ) m.HydrogenFixedCostAnnual = Expression(m.PERIODS, rule=lambda m, p: sum( m.ElectrolyzerCapacityMW[z, p] * ( m.hydrogen_electrolyzer_capital_cost_per_mw * crf(m.interest_rate, m.hydrogen_electrolyzer_life_years) + m.hydrogen_electrolyzer_fixed_cost_per_mw_year) + m.LiquifierCapacityKgPerHour[z, p] * ( m.hydrogen_liquifier_capital_cost_per_kg_per_hour * crf(m.interest_rate, m.hydrogen_liquifier_life_years) + m.hydrogen_liquifier_fixed_cost_per_kg_hour_year) + m.LiquidHydrogenTankCapacityKg[z, p] * ( m.liquid_hydrogen_tank_capital_cost_per_kg * crf(m.interest_rate, m.liquid_hydrogen_tank_life_years)) + m.FuelCellCapacityMW[z, p] * ( m.hydrogen_fuel_cell_capital_cost_per_mw * crf(m.interest_rate, m.hydrogen_fuel_cell_life_years) + m.hydrogen_fuel_cell_fixed_cost_per_mw_year) for z in m.LOAD_ZONES ) ) m.Cost_Components_Per_TP.append('HydrogenVariableCost') m.Cost_Components_Per_Period.append('HydrogenFixedCostAnnual') # Register with spinning reserves if it is available if [rt.lower() for rt in m.options.hydrogen_reserve_types] != ['none']: # Register with spinning reserves if hasattr(m, 'Spinning_Reserve_Up_Provisions'): # calculate available slack from hydrogen equipment m.HydrogenSlackUpForArea = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: sum(m.HydrogenSlackUp[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) m.HydrogenSlackDownForArea = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: sum(m.HydrogenSlackDown[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) if hasattr(m, 'GEN_SPINNING_RESERVE_TYPES'): # using advanced formulation, index by reserve type, balancing area, timepoint # define variables for each type of reserves to be provided # choose how to allocate the slack between the different reserve products m.HYDROGEN_SPINNING_RESERVE_TYPES = Set( initialize=m.options.hydrogen_reserve_types ) m.HydrogenSpinningReserveUp = Var( m.HYDROGEN_SPINNING_RESERVE_TYPES, m.BALANCING_AREA_TIMEPOINTS, within=NonNegativeReals ) m.HydrogenSpinningReserveDown = Var( m.HYDROGEN_SPINNING_RESERVE_TYPES, m.BALANCING_AREA_TIMEPOINTS, within=NonNegativeReals ) # constrain reserve provision within available slack m.Limit_HydrogenSpinningReserveUp = Constraint( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, ba, tp: sum( m.HydrogenSpinningReserveUp[rt, ba, tp] for rt in m.HYDROGEN_SPINNING_RESERVE_TYPES ) <= m.HydrogenSlackUpForArea[ba, tp] ) m.Limit_HydrogenSpinningReserveDown = Constraint( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, ba, tp: sum( m.HydrogenSpinningReserveDown[rt, ba, tp] for rt in m.HYDROGEN_SPINNING_RESERVE_TYPES ) <= m.HydrogenSlackDownForArea[ba, tp] ) m.Spinning_Reserve_Up_Provisions.append('HydrogenSpinningReserveUp') m.Spinning_Reserve_Down_Provisions.append('HydrogenSpinningReserveDown') else: # using older formulation, only one type of spinning reserves, indexed by balancing area, timepoint if m.options.hydrogen_reserve_types != ['spinning']: raise ValueError( 'Unable to use reserve types other than "spinning" with simple spinning reserves module.' ) m.Spinning_Reserve_Up_Provisions.append('HydrogenSlackUpForArea') m.Spinning_Reserve_Down_Provisions.append('HydrogenSlackDownForArea')
def define_components(mod): """ STORAGE_GENS is the subset of projects that can provide energy storage. STORAGE_GEN_BLD_YRS is the subset of GEN_BLD_YRS, restricted to storage projects. gen_storage_efficiency[STORAGE_GENS] describes the round trip efficiency of a storage technology. A storage technology that is 75 percent efficient would have a storage_efficiency of .75. If 1 MWh was stored in such a storage project, 750 kWh would be available for extraction later. Internal leakage or energy dissipation of storage technologies is assumed to be neglible, which is consistent with short-duration storage technologies currently on the market which tend to consume stored power within 1 day. If a given storage technology has significant internal discharge when it stores power for extended time perios, then those behaviors will need to be modeled in more detail. gen_store_to_release_ratio[STORAGE_GENS] describes the maximum rate that energy can be stored, expressed as a ratio of discharge power capacity. This is an optional parameter and will default to 1. If a storage project has 1 MW of dischage capacity and a gen_store_to_release_ratio of 1.2, then it can consume up to 1.2 MW of power while charging. gen_storage_energy_to_power_ratio[STORAGE_GENS], if specified, restricts the storage capacity (in MWh) to be a fixed multiple of the output power (in MW), i.e., specifies a particular number of hours of storage capacity. Omit this column or specify "." to allow Switch to choose the energy/power ratio. (Note: gen_storage_energy_overnight_cost or gen_overnight_cost should often be set to 0 when using this.) gen_storage_max_cycles_per_year[STORAGE_GENS], if specified, restricts the number of charge/discharge cycles each storage project can perform per year; one cycle is defined as discharging an amount of energy equal to the storage capacity of the project. gen_storage_energy_overnight_cost[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is the overnight capital cost per MWh of energy capacity for building the given storage technology installed in the given investment period. This is only defined for storage technologies. Note that this describes the energy component and the overnight_cost describes the power component. BuildStorageEnergy[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is a decision of how much energy capacity to build onto a storage project. This is analogous to BuildGen, but for energy rather than power. StorageEnergyInstallCosts[PERIODS] is an expression of the annual costs incurred by the BuildStorageEnergy decision. StorageEnergyCapacity[g, period] is an expression describing the cumulative available energy capacity of BuildStorageEnergy. This is analogous to GenCapacity. STORAGE_GEN_TPS is the subset of GEN_TPS, restricted to storage projects. ChargeStorage[(g, t) in STORAGE_GEN_TPS] is a dispatch decision of how much to charge a storage project in each timepoint. StorageNetCharge[LOAD_ZONE, TIMEPOINT] is an expression describing the aggregate impact of ChargeStorage in each load zone and timepoint. Charge_Storage_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains ChargeStorage to available power capacity (accounting for gen_store_to_release_ratio) StateOfCharge[(g, t) in STORAGE_GEN_TPS] is a variable for tracking state of charge. This value stores the state of charge at the end of each timepoint for each storage project. Track_State_Of_Charge[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on the StateOfCharge in the previous timepoint, ChargeStorage and DispatchGen. State_Of_Charge_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on installed energy capacity. """ mod.STORAGE_GENS = Set(within=mod.GENERATION_PROJECTS) mod.STORAGE_GEN_PERIODS = Set( within=mod.GEN_PERIODS, initialize=lambda m: [(g, p) for g in m.STORAGE_GENS for p in m.PERIODS_FOR_GEN[g]] ) mod.gen_storage_efficiency = Param( mod.STORAGE_GENS, within=PercentFraction) # TODO: rename to gen_charge_to_discharge_ratio? mod.gen_store_to_release_ratio = Param( mod.STORAGE_GENS, within=NonNegativeReals, default=1.0) mod.gen_storage_energy_to_power_ratio = Param( mod.STORAGE_GENS, within=NonNegativeReals, default=float("inf")) # inf is a flag that no value is specified (nan and None don't work) mod.gen_storage_max_cycles_per_year = Param( mod.STORAGE_GENS, within=NonNegativeReals, default=float('inf')) # TODO: build this set up instead of filtering down, to improve performance mod.STORAGE_GEN_BLD_YRS = Set( dimen=2, initialize=mod.GEN_BLD_YRS, filter=lambda m, g, bld_yr: g in m.STORAGE_GENS) mod.gen_storage_energy_overnight_cost = Param( mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_storage_energy_overnight_cost') mod.BuildStorageEnergy = Var( mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) # Summarize capital costs of energy storage for the objective function. mod.StorageEnergyInstallCosts = Expression( mod.PERIODS, rule=lambda m, p: sum(m.BuildStorageEnergy[g, bld_yr] * m.gen_storage_energy_overnight_cost[g, bld_yr] * crf(m.interest_rate, m.gen_max_age[g]) for (g, bld_yr) in m.STORAGE_GEN_BLD_YRS)) mod.Cost_Components_Per_Period.append( 'StorageEnergyInstallCosts') mod.StorageEnergyCapacity = Expression( mod.STORAGE_GENS, mod.PERIODS, rule=lambda m, g, period: sum( m.BuildStorageEnergy[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, period])) mod.STORAGE_GEN_TPS = Set( dimen=2, initialize=lambda m: ( (g, tp) for g in m.STORAGE_GENS for tp in m.TPS_FOR_GEN[g])) mod.ChargeStorage = Var( mod.STORAGE_GEN_TPS, within=NonNegativeReals) # Summarize storage charging for the energy balance equations # TODO: rename this StorageTotalCharging or similar (to indicate it's a # sum for a zone, not a net quantity for a project) def rule(m, z, t): # Construct and cache a set for summation as needed if not hasattr(m, 'Storage_Charge_Summation_dict'): m.Storage_Charge_Summation_dict = collections.defaultdict(set) for g, t2 in m.STORAGE_GEN_TPS: z2 = m.gen_load_zone[g] m.Storage_Charge_Summation_dict[z2, t2].add(g) # Use pop to free memory relevant_projects = m.Storage_Charge_Summation_dict.pop((z, t), {}) return sum(m.ChargeStorage[g, t] for g in relevant_projects) mod.StorageNetCharge = Expression(mod.LOAD_ZONES, mod.TIMEPOINTS, rule=rule) # Register net charging with zonal energy balance. Discharging is already # covered by DispatchGen. mod.Zone_Power_Withdrawals.append('StorageNetCharge') # use fixed energy/power ratio (# hours of capacity) when specified mod.Enforce_Fixed_Energy_Storage_Ratio = Constraint( mod.STORAGE_GEN_BLD_YRS, rule=lambda m, g, y: Constraint.Skip if m.gen_storage_energy_to_power_ratio[g] == float("inf") # no value specified else (m.BuildStorageEnergy[g, y] == m.gen_storage_energy_to_power_ratio[g] * m.BuildGen[g, y]) ) def Charge_Storage_Upper_Limit_rule(m, g, t): return m.ChargeStorage[g,t] <= \ m.DispatchUpperLimit[g, t] * m.gen_store_to_release_ratio[g] mod.Charge_Storage_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=Charge_Storage_Upper_Limit_rule) mod.StateOfCharge = Var( mod.STORAGE_GEN_TPS, within=NonNegativeReals) def Track_State_Of_Charge_rule(m, g, t): return m.StateOfCharge[g, t] == \ m.StateOfCharge[g, m.tp_previous[t]] + \ (m.ChargeStorage[g, t] * m.gen_storage_efficiency[g] - m.DispatchGen[g, t]) * m.tp_duration_hrs[t] mod.Track_State_Of_Charge = Constraint( mod.STORAGE_GEN_TPS, rule=Track_State_Of_Charge_rule) def State_Of_Charge_Upper_Limit_rule(m, g, t): return m.StateOfCharge[g, t] <= \ m.StorageEnergyCapacity[g, m.tp_period[t]] mod.State_Of_Charge_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=State_Of_Charge_Upper_Limit_rule) # batteries can only complete the specified number of cycles per year, averaged over each period mod.Battery_Cycle_Limit = Constraint( mod.STORAGE_GEN_PERIODS, rule=lambda m, g, p: # solvers sometimes perform badly with infinite constraint Constraint.Skip if m.gen_storage_max_cycles_per_year[g] == float('inf') else ( sum(m.DispatchGen[g, tp] * m.tp_duration_hrs[tp] for tp in m.TPS_IN_PERIOD[p]) <= m.gen_storage_max_cycles_per_year[g] * m.StorageEnergyCapacity[g, p] * m.period_length_years[p] ) )
def define_components(mod): """ Adds components to a Pyomo abstract model object to describe generation and storage projects. Unless otherwise stated, all power capacity is specified in units of MW and all sets and parameters are mandatory. GENERATION_PROJECTS is the set of generation and storage projects that have been built or could potentially be built. A project is a combination of generation technology, load zone and location. A particular build-out of a project should also include the year in which construction was complete and additional capacity came online. Members of this set are abbreviated as gen in parameter names and g in indexes. Use of p instead of g is discouraged because p is reserved for period. gen_dbid[g] is an external database id for each generation project. This is an optional parameter than defaults to the project index. gen_tech[g] describes what kind of technology a generation project is using. gen_load_zone[g] is the load zone this generation project is built in. VARIABLE_GENS is a subset of GENERATION_PROJECTS that only includes variable generators such as wind or solar that have exogenous constraints on their energy production. BASELOAD_GENS is a subset of GENERATION_PROJECTS that only includes baseload generators such as coal or geothermal. GENS_IN_ZONE[z in LOAD_ZONES] is an indexed set that lists all generation projects within each load zone. CAPACITY_LIMITED_GENS is the subset of GENERATION_PROJECTS that are capacity limited. Most of these will be generator types that are resource limited like wind, solar or geothermal, but this can be specified for any generation project. Some existing or proposed generation projects may have upper bounds on increasing capacity or replacing capacity as it is retired based on permits or local air quality regulations. gen_capacity_limit_mw[g] is defined for generation technologies that are resource limited and do not compete for land area. This describes the maximum possible capacity of a generation project in units of megawatts. -- CONSTRUCTION -- GEN_BLD_YRS is a two-dimensional set of generation projects and the years in which construction or expansion occured or can occur. You can think of a project as a physical site that can be built out over time. BuildYear is the year in which construction is completed and new capacity comes online, not the year when constrution begins. BuildYear will be in the past for existing projects and will be the first year of an investment period for new projects. Investment decisions are made for each project/invest period combination. This set is derived from other parameters for all new construction. This set also includes entries for existing projects that have already been built and planned projects whose capacity buildouts have already been decided; information for legacy projects come from other files and their build years will usually not correspond to the set of investment periods. There are two recommended options for abbreviating this set for denoting indexes: typically this should be written out as (g, build_year) for clarity, but when brevity is more important (g, b) is acceptable. NEW_GEN_BLD_YRS is a subset of GEN_BLD_YRS that only includes projects that have not yet been constructed. This is derived by joining the set of GENERATION_PROJECTS with the set of NEW_GENERATION_BUILDYEARS using generation technology. PREDETERMINED_GEN_BLD_YRS is a subset of GEN_BLD_YRS that only includes existing or planned projects that are not subject to optimization. gen_predetermined_cap[(g, build_year) in PREDETERMINED_GEN_BLD_YRS] is a parameter that describes how much capacity was built in the past for existing projects, or is planned to be built for future projects. BuildGen[g, build_year] is a decision variable that describes how much capacity of a project to install in a given period. This also stores the amount of capacity that was installed in existing projects that are still online. GenCapacity[g, period] is an expression that returns the total capacity online in a given period. This is the sum of installed capacity minus all retirements. Max_Build_Potential[g] is a constraint defined for each project that enforces maximum capacity limits for resource-limited projects. GenCapacity <= gen_capacity_limit_mw NEW_GEN_WITH_MIN_BUILD_YEARS is the subset of NEW_GEN_BLD_YRS for which minimum capacity build-out constraints will be enforced. BuildMinGenCap[g, build_year] is a binary variable that indicates whether a project will build capacity in a period or not. If the model is committing to building capacity, then the minimum must be enforced. Enforce_Min_Build_Lower[g, build_year] and Enforce_Min_Build_Upper[g, build_year] are a pair of constraints that force project build-outs to meet the minimum build requirements for generation technologies that have those requirements. They force BuildGen to be 0 when BuildMinGenCap is 0, and to be greater than g_min_build_capacity when BuildMinGenCap is 1. In the latter case, the upper constraint should be non-binding; the upper limit is set to 10 times the peak non-conincident demand of the entire system. --- OPERATIONS --- PERIODS_FOR_GEN_BLD_YR[g, build_year] is an indexed set that describes which periods a given project build will be operational. BLD_YRS_FOR_GEN_PERIOD[g, period] is a complementary indexed set that identify which build years will still be online for the given project in the given period. For some project-period combinations, this will be an empty set. GEN_PERIODS describes periods in which generation projects could be operational. Unlike the related sets above, it is not indexed. Instead it is specified as a set of (g, period) combinations useful for indexing other model components. --- COSTS --- gen_connect_cost_per_mw[g] is the cost of grid upgrades to support a new project, in dollars per peak MW. These costs include new transmission lines to a substation, substation upgrades and any other grid upgrades that are needed to deliver power from the interconnect point to the load center or from the load center to the broader transmission network. The following cost components are defined for each project and build year. These parameters will always be available, but will typically be populated by the generic costs specified in generator costs inputs file and the load zone cost adjustment multipliers from load_zones inputs file. gen_overnight_cost[g, build_year] is the overnight capital cost per MW of capacity for building a project in the given period. By "installed in the given period", I mean that it comes online at the beginning of the given period and construction starts before that. gen_fixed_om[g, build_year] is the annual fixed Operations and Maintenance costs (O&M) per MW of capacity for given project that was installed in the given period. -- Derived cost parameters -- gen_capital_cost_annual[g, build_year] is the annualized loan payments for a project's capital and connection costs in units of $/MW per year. This is specified in non-discounted real dollars in a future period, not real dollars in net present value. Proj_Fixed_Costs_Annual[g, period] is the total annual fixed costs (capital as well as fixed operations & maintenance) incurred by a project in a period. This reflects all of the builds are operational in the given period. This is an expression that reflect decision variables. ProjFixedCosts[period] is the sum of Proj_Fixed_Costs_Annual[g, period] for all projects that could be online in the target period. This aggregation is performed for the benefit of the objective function. TODO: - Allow early capacity retirements with savings on fixed O&M """ mod.GENERATION_PROJECTS = Set() mod.gen_dbid = Param(mod.GENERATION_PROJECTS, default=lambda m, g: g) mod.gen_tech = Param(mod.GENERATION_PROJECTS) mod.GENERATION_TECHNOLOGIES = Set(initialize=lambda m: {m.gen_tech[g] for g in m.GENERATION_PROJECTS} ) mod.gen_energy_source = Param(mod.GENERATION_PROJECTS, validate=lambda m,val,g: val in m.ENERGY_SOURCES or val == "multiple") mod.gen_load_zone = Param(mod.GENERATION_PROJECTS, within=mod.LOAD_ZONES) mod.gen_max_age = Param(mod.GENERATION_PROJECTS, within=PositiveIntegers) mod.gen_is_variable = Param(mod.GENERATION_PROJECTS, within=Boolean) mod.gen_is_baseload = Param(mod.GENERATION_PROJECTS, within=Boolean) mod.gen_is_cogen = Param(mod.GENERATION_PROJECTS, within=Boolean, default=False) mod.gen_is_distributed = Param(mod.GENERATION_PROJECTS, within=Boolean, default=False) mod.gen_scheduled_outage_rate = Param(mod.GENERATION_PROJECTS, within=PercentFraction, default=0) mod.gen_forced_outage_rate = Param(mod.GENERATION_PROJECTS, within=PercentFraction, default=0) mod.min_data_check('GENERATION_PROJECTS', 'gen_tech', 'gen_energy_source', 'gen_load_zone', 'gen_max_age', 'gen_is_variable', 'gen_is_baseload') mod.GENS_IN_ZONE = Set( mod.LOAD_ZONES, initialize=lambda m, z: set( g for g in m.GENERATION_PROJECTS if m.gen_load_zone[g] == z)) mod.VARIABLE_GENS = Set( initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_is_variable[g]) mod.BASELOAD_GENS = Set( initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_is_baseload[g]) mod.CAPACITY_LIMITED_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_capacity_limit_mw = Param( mod.CAPACITY_LIMITED_GENS, within=PositiveReals) mod.DISCRETELY_SIZED_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_unit_size = Param( mod.DISCRETELY_SIZED_GENS, within=PositiveReals) mod.CCS_EQUIPPED_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_ccs_capture_efficiency = Param( mod.CCS_EQUIPPED_GENS, within=PercentFraction) mod.gen_ccs_energy_load = Param( mod.CCS_EQUIPPED_GENS, within=PercentFraction) mod.gen_uses_fuel = Param( mod.GENERATION_PROJECTS, initialize=lambda m, g: ( m.gen_energy_source[g] in m.FUELS or m.gen_energy_source[g] == "multiple")) mod.NON_FUEL_BASED_GENS = Set( initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: not m.gen_uses_fuel[g]) mod.FUEL_BASED_GENS = Set( initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_uses_fuel[g]) mod.gen_full_load_heat_rate = Param( mod.FUEL_BASED_GENS, within=PositiveReals) mod.MULTIFUEL_GENS = Set( initialize=mod.GENERATION_PROJECTS, filter=lambda m, g: m.gen_energy_source[g] == "multiple") mod.FUELS_FOR_MULTIFUEL_GEN = Set(mod.MULTIFUEL_GENS, within=mod.FUELS) mod.FUELS_FOR_GEN = Set(mod.FUEL_BASED_GENS, initialize=lambda m, g: ( m.FUELS_FOR_MULTIFUEL_GEN[g] if g in m.MULTIFUEL_GENS else [m.gen_energy_source[g]])) mod.PREDETERMINED_GEN_BLD_YRS = Set( dimen=2) mod.GEN_BLD_YRS = Set( dimen=2, validate=lambda m, g, bld_yr: ( (g, bld_yr) in m.PREDETERMINED_GEN_BLD_YRS or (g, bld_yr) in m.GENERATION_PROJECTS * m.PERIODS)) mod.NEW_GEN_BLD_YRS = Set( dimen=2, initialize=lambda m: m.GEN_BLD_YRS - m.PREDETERMINED_GEN_BLD_YRS) mod.gen_predetermined_cap = Param( mod.PREDETERMINED_GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_predetermined_cap') def _gen_build_can_operate_in_period(m, g, build_year, period): if build_year in m.PERIODS: online = m.period_start[build_year] else: online = build_year retirement = online + m.gen_max_age[g] return ( online <= m.period_start[period] < retirement ) # This is probably more correct, but is a different behavior # mid_period = m.period_start[period] + 0.5 * m.period_length_years[period] # return online <= m.period_start[period] and mid_period <= retirement # The set of periods when a project built in a certain year will be online mod.PERIODS_FOR_GEN_BLD_YR = Set( mod.GEN_BLD_YRS, within=mod.PERIODS, ordered=True, initialize=lambda m, g, bld_yr: set( period for period in m.PERIODS if _gen_build_can_operate_in_period(m, g, bld_yr, period))) # The set of build years that could be online in the given period # for the given project. mod.BLD_YRS_FOR_GEN_PERIOD = Set( mod.GENERATION_PROJECTS, mod.PERIODS, initialize=lambda m, g, period: set( bld_yr for (gen, bld_yr) in m.GEN_BLD_YRS if gen == g and _gen_build_can_operate_in_period(m, g, bld_yr, period))) def bounds_BuildGen(model, g, bld_yr): if((g, bld_yr) in model.PREDETERMINED_GEN_BLD_YRS): return (model.gen_predetermined_cap[g, bld_yr], model.gen_predetermined_cap[g, bld_yr]) elif(g in model.CAPACITY_LIMITED_GENS): # This does not replace Max_Build_Potential because # Max_Build_Potential applies across all build years. return (0, model.gen_capacity_limit_mw[g]) else: return (0, None) mod.BuildGen = Var( mod.GEN_BLD_YRS, within=NonNegativeReals, bounds=bounds_BuildGen) # Some projects are retired before the first study period, so they # don't appear in the objective function or any constraints. # In this case, pyomo may leave the variable value undefined even # after a solve, instead of assigning a value within the allowed # range. This causes errors in the Progressive Hedging code, which # expects every variable to have a value after the solve. So as a # starting point we assign an appropriate value to all the existing # projects here. def BuildGen_assign_default_value(m, g, bld_yr): m.BuildGen[g, bld_yr] = m.gen_predetermined_cap[g, bld_yr] mod.BuildGen_assign_default_value = BuildAction( mod.PREDETERMINED_GEN_BLD_YRS, rule=BuildGen_assign_default_value) mod.GEN_PERIODS = Set( dimen=2, initialize=mod.GENERATION_PROJECTS * mod.PERIODS) mod.GenCapacity = Expression( mod.GEN_PERIODS, rule=lambda m, g, period: sum( m.BuildGen[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, period])) mod.Max_Build_Potential = Constraint( mod.CAPACITY_LIMITED_GENS, mod.PERIODS, rule=lambda m, g, p: ( m.gen_capacity_limit_mw[g] >= m.GenCapacity[g, p])) # The following components enforce minimum capacity build-outs. # Note that this adds binary variables to the model. mod.gen_min_build_capacity = Param (mod.GENERATION_PROJECTS, within=NonNegativeReals, default=0) mod.NEW_GEN_WITH_MIN_BUILD_YEARS = Set( initialize=mod.NEW_GEN_BLD_YRS, filter=lambda m, g, p: ( m.gen_min_build_capacity[g] > 0)) mod.BuildMinGenCap = Var( mod.NEW_GEN_WITH_MIN_BUILD_YEARS, within=Binary) mod.Enforce_Min_Build_Lower = Constraint( mod.NEW_GEN_WITH_MIN_BUILD_YEARS, rule=lambda m, g, p: ( m.BuildMinGenCap[g, p] * m.gen_min_build_capacity[g] <= m.BuildGen[g, p])) # Define a constant for enforcing binary constraints on project capacity # The value of 100 GW should be larger than any expected build size. For # perspective, the world's largest electric power plant (Three Gorges Dam) # is 22.5 GW. I tried using 1 TW, but CBC had numerical stability problems # with that value and chose a suboptimal solution for the # discrete_and_min_build example which is installing capacity of 3-5 MW. mod._gen_max_cap_for_binary_constraints = 10**5 mod.Enforce_Min_Build_Upper = Constraint( mod.NEW_GEN_WITH_MIN_BUILD_YEARS, rule=lambda m, g, p: ( m.BuildGen[g, p] <= m.BuildMinGenCap[g, p] * mod._gen_max_cap_for_binary_constraints)) # Costs mod.gen_variable_om = Param (mod.GENERATION_PROJECTS, within=NonNegativeReals) mod.gen_connect_cost_per_mw = Param(mod.GENERATION_PROJECTS, within=NonNegativeReals) mod.min_data_check('gen_variable_om', 'gen_connect_cost_per_mw') mod.gen_overnight_cost = Param( mod.GEN_BLD_YRS, within=NonNegativeReals) mod.gen_fixed_om = Param( mod.GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_overnight_cost', 'gen_fixed_om') # Derived annual costs mod.gen_capital_cost_annual = Param( mod.GEN_BLD_YRS, initialize=lambda m, g, bld_yr: ( (m.gen_overnight_cost[g, bld_yr] + m.gen_connect_cost_per_mw[g]) * crf(m.interest_rate, m.gen_max_age[g]))) mod.GenCapitalCosts = Expression( mod.GEN_PERIODS, rule=lambda m, g, p: sum( m.BuildGen[g, bld_yr] * m.gen_capital_cost_annual[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, p])) mod.GenFixedOMCosts = Expression( mod.GEN_PERIODS, rule=lambda m, g, p: sum( m.BuildGen[g, bld_yr] * m.gen_fixed_om[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, p])) # Summarize costs for the objective function. Units should be total # annual future costs in $base_year real dollars. The objective # function will convert these to base_year Net Present Value in # $base_year real dollars. mod.TotalGenFixedCosts = Expression( mod.PERIODS, rule=lambda m, p: sum( m.GenCapitalCosts[g, p] + m.GenFixedOMCosts[g, p] for g in m.GENERATION_PROJECTS)) mod.Cost_Components_Per_Period.append('TotalGenFixedCosts')
def define_components(m): # TODO: change this to allow multiple storage technologies. # battery capital cost # TODO: accept a single battery_capital_cost_per_mwh_capacity value or the annual values shown here m.BATTERY_CAPITAL_COST_YEARS = Set( ) # list of all years for which capital costs are available m.battery_capital_cost_per_mwh_capacity_by_year = Param( m.BATTERY_CAPITAL_COST_YEARS) # TODO: merge this code with batteries.py and auto-select between fixed calendar life and cycle life # based on whether battery_n_years or battery_n_cycles is provided. (Or find some hybrid that can # handle both well?) # number of years the battery can last; we assume there is no limit on cycle life within this period m.battery_n_years = Param() # maximum depth of discharge m.battery_max_discharge = Param() # round-trip efficiency m.battery_efficiency = Param() # fastest time that storage can be emptied (down to max_discharge) m.battery_min_discharge_time = Param() # amount of battery capacity to build and use (in MWh) # TODO: integrate this with other project data, so it can contribute to reserves, etc. m.BuildBattery = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) m.Battery_Capacity = Expression( m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildBattery[z, bld_yr] for bld_yr in m. CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p] if bld_yr + m.battery_n_years > p)) # rate of charging/discharging battery m.ChargeBattery = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.DischargeBattery = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) # storage level at start of each timepoint m.BatteryLevel = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) # add storage dispatch to the zonal energy balance m.Zone_Power_Injections.append('DischargeBattery') m.Zone_Power_Withdrawals.append('ChargeBattery') # add the batteries to the objective function # cost recovery for any battery capacity currently active m.BatteryAnnualCost = Expression( m.PERIODS, rule=lambda m, p: sum( m.BuildBattery[z, bld_yr] * m. battery_capital_cost_per_mwh_capacity_by_year[bld_yr] * crf( m.interest_rate, m.battery_n_years) for bld_yr in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p] if bld_yr + m.battery_n_years > p for z in m.LOAD_ZONES)) m.Cost_Components_Per_Period.append('BatteryAnnualCost') # Calculate the state of charge based on conservation of energy # NOTE: this is circular for each day # NOTE: the overall level for the day is free, but the levels each timepoint are chained. m.Battery_Level_Calc = Constraint( m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.BatteryLevel[z, t] == m.BatteryLevel[ z, m.tp_previous[t]] + m.tp_duration_hrs[t] * (m.battery_efficiency * m.ChargeBattery[z, m.tp_previous[t]] - m. DischargeBattery[z, m.tp_previous[t]])) # limits on storage level m.Battery_Min_Level = Constraint( m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: (1.0 - m.battery_max_discharge) * m. Battery_Capacity[z, m.tp_period[t]] <= m.BatteryLevel[z, t]) m.Battery_Max_Level = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.BatteryLevel[z, t] <= m.Battery_Capacity[z, m.tp_period[t]]) m.Battery_Max_Charge_Rate = Constraint( m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.ChargeBattery[z, t] <= # changed 2018-02-20 to allow full discharge in min_discharge_time, # (previously pegged to battery_max_discharge) m.Battery_Capacity[z, m.tp_period[t]] / m.battery_min_discharge_time) m.Battery_Max_Discharge_Rate = Constraint( m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.DischargeBattery[z, t] <= m.Battery_Capacity[ z, m.tp_period[t]] / m.battery_min_discharge_time) # how much could output/input be increased on short notice (to provide reserves) m.BatterySlackUp = Expression( m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.Battery_Capacity[z, m.tp_period[ t]] / m.battery_min_discharge_time - m.DischargeBattery[ z, t] + m.ChargeBattery[z, t]) m.BatterySlackDown = Expression( m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.Battery_Capacity[z, m.tp_period[ t]] / m.battery_min_discharge_time - m.ChargeBattery[ z, t] + m.DischargeBattery[z, t]) # assume batteries can only complete one full cycle per day, averaged over each period # (this was pegged to battery_max_discharge before 2018-02-20) m.Battery_Cycle_Limit = Constraint( m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.DischargeBattery[z, tp] * m.tp_duration_hrs[ tp] for tp in m.TPS_IN_PERIOD[p]) <= m.Battery_Capacity[ z, p] * m.period_length_hours[p]) # Register with spinning reserves if it is available if hasattr(m, 'Spinning_Reserve_Up_Provisions'): m.BatterySpinningReserveUp = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: sum(m.BatterySlackUp[z, t] for z in m.ZONES_IN_BALANCING_AREA[b])) m.Spinning_Reserve_Up_Provisions.append('BatterySpinningReserveUp') m.BatterySpinningReserveDown = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: \ sum(m.BatterySlackDown[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) m.Spinning_Reserve_Down_Provisions.append('BatterySpinningReserveDown')
def define_components(mod): """ Adds components to a Pyomo abstract model object to describe bulk transmission of an electric grid. This includes parameters, build decisions and constraints. Unless otherwise stated, all power capacity is specified in units of MW and all sets and parameters are mandatory. TRANSMISSION_LINES is the complete set of transmission pathways connecting load zones. Each member of this set is a one dimensional identifier such as "A-B". This set has no regard for directionality of transmission lines and will generate an error if you specify two lines that move in opposite directions such as (A to B) and (B to A). Another derived set - TRANS_LINES_DIRECTIONAL - stores directional information. Transmission may be abbreviated as trans or tx in parameter names or indexes. trans_lz1[tx] and trans_lz2[tx] specify the load zones at either end of a transmission line. The order of 1 and 2 is unimportant, but you are encouraged to be consistent to simplify merging information back into external databases. trans_dbid[tx in TRANSMISSION_LINES] is an external database identifier for each transmission line. This is an optional parameter than defaults to the identifier of the transmission line. trans_length_km[tx in TRANSMISSION_LINES] is the length of each transmission line in kilometers. trans_efficiency[tx in TRANSMISSION_LINES] is the proportion of energy sent down a line that is delivered. If 2 percent of energy sent down a line is lost, this value would be set to 0.98. trans_new_build_allowed[tx in TRANSMISSION_LINES] is a binary value indicating whether new transmission build-outs are allowed along a transmission line. This optional parameter defaults to True. BLD_YRS_FOR_TX is the set of transmission lines and years in which they have been or could be built. This set includes past and potential future builds. All future builds must come online in the first year of an investment period. This set is composed of two elements with members: (tx, build_year). For existing transmission where the build years are not known, build_year is set to 'Legacy'. BLD_YRS_FOR_EXISTING_TX is a subset of BLD_YRS_FOR_TX that lists builds that happened before the first investment period. For most datasets the build year is unknown, so is it always set to 'Legacy'. existing_trans_cap[tx in TRANSMISSION_LINES] is a parameter that describes how many MW of capacity has been installed before the start of the study. NEW_TRANS_BLD_YRS is a subset of BLD_YRS_FOR_TX that describes potential builds. BuildTx[(tx, bld_yr) in BLD_YRS_FOR_TX] is a decision variable that describes the transfer capacity in MW installed on a corridor in a given build year. For existing builds, this variable is locked to the existing capacity. TxCapacityNameplate[(tx, bld_yr) in BLD_YRS_FOR_TX] is an expression that returns the total nameplate transfer capacity of a transmission line in a given period. This is the sum of existing and newly-build capacity. trans_derating_factor[tx in TRANSMISSION_LINES] is an overall derating factor for each transmission line that can reflect forced outage rates, stability or contingency limitations. This parameter is optional and defaults to 1. This parameter should be in the range of 0 to 1, being 0 a value that disables the line completely. TxCapacityNameplateAvailable[(tx, bld_yr) in BLD_YRS_FOR_TX] is an expression that returns the available transfer capacity of a transmission line in a given period, taking into account the nameplate capacity and derating factor. trans_terrain_multiplier[tx in TRANSMISSION_LINES] is a cost adjuster applied to each transmission line that reflects the additional costs that may be incurred for traversing that specific terrain. Crossing mountains or cities will be more expensive than crossing plains. This parameter is optional and defaults to 1. This parameter should be in the range of 0.5 to 3. trans_capital_cost_per_mw_km describes the generic costs of building new transmission in units of $BASE_YEAR per MW transfer capacity per km. This is optional and defaults to 1000. trans_lifetime_yrs is the number of years in which a capital construction loan for a new transmission line is repaid. This optional parameter defaults to 20 years based on 2009 WREZ transmission model transmission data. At the end of this time, we assume transmission lines will be rebuilt at the same cost. trans_fixed_om_fraction is describes the fixed Operations and Maintenance costs as a fraction of capital costs. This optional parameter defaults to 0.03 based on 2009 WREZ transmission model transmission data costs for existing transmission maintenance. trans_cost_hourly[tx TRANSMISSION_LINES] is the cost of building transmission lines in units of $BASE_YEAR / MW- transfer-capacity / hour. This derived parameter is based on the total annualized capital and fixed O&M costs, then divides that by hours per year to determine the portion of costs incurred hourly. DIRECTIONAL_TX is a derived set of directional paths that electricity can flow along transmission lines. Each element of this set is a two-dimensional entry that describes the origin and destination of the flow: (load_zone_from, load_zone_to). Every transmission line will generate two entries in this set. Members of this set are abbreviated as trans_d where possible, but may be abbreviated as tx in situations where brevity is important and it is unlikely to be confused with the overall transmission line. trans_d_line[trans_d] is the transmission line associated with this directional path. TX_BUILDS_IN_PERIOD[p in PERIODS] is an indexed set that describes which transmission builds will be operational in a given period. Currently, transmission lines are kept online indefinitely, with parts being replaced as they wear out. TX_BUILDS_IN_PERIOD[p] will return a subset of (tx, bld_yr) in BLD_YRS_FOR_TX. --- Delayed implementation --- is_dc_line ... Do I even need to implement this? --- NOTES --- The cost stream over time for transmission lines differs from the SWITCH-WECC model. The SWITCH-WECC model assumed new transmission had a financial lifetime of 20 years, which was the length of the loan term. During this time, fixed operations & maintenance costs were also incurred annually and these were estimated to be 3 percent of the initial capital costs. These fixed O&M costs were obtained from the 2009 WREZ transmission model transmission data costs for existing transmission maintenance .. most of those lines were old and their capital loans had been paid off, so the O&M were the costs of keeping them operational. SWITCH-WECC basically assumed the lines could be kept online indefinitely with that O&M budget, with components of the lines being replaced as needed. This payment schedule and lifetimes was assumed to hold for both existing and new lines. This made the annual costs change over time, which could create edge effects near the end of the study period. SWITCH-WECC had different cost assumptions for local T&D; capital expenses and fixed O&M expenses were rolled in together, and those were assumed to continue indefinitely. This basically assumed that local T&D would be replaced at the end of its financial lifetime. SWITCH-Pyomo treats all transmission and distribution (long- distance or local) the same. Any capacity that is built will be kept online indefinitely. At the end of its financial lifetime, existing capacity will be retired and rebuilt, so the annual cost of a line upgrade will remain constant in every future year. """ mod.TRANSMISSION_LINES = Set() mod.trans_lz1 = Param(mod.TRANSMISSION_LINES, within=mod.LOAD_ZONES) mod.trans_lz2 = Param(mod.TRANSMISSION_LINES, within=mod.LOAD_ZONES) # we don't do a min_data_check for TRANSMISSION_LINES, because it may be empty for model # configurations that are sometimes run with interzonal transmission and sometimes not # (e.g., island interconnect scenarios). However, presence of this column will still be # checked by load_data_aug. mod.min_data_check('trans_lz1', 'trans_lz2') mod.trans_dbid = Param(mod.TRANSMISSION_LINES, default=lambda m, tx: tx) mod.trans_length_km = Param(mod.TRANSMISSION_LINES, within=NonNegativeReals) mod.trans_efficiency = Param(mod.TRANSMISSION_LINES, within=PercentFraction) mod.BLD_YRS_FOR_EXISTING_TX = Set(dimen=2, initialize=lambda m: set( (tx, 'Legacy') for tx in m.TRANSMISSION_LINES)) mod.existing_trans_cap = Param(mod.TRANSMISSION_LINES, within=NonNegativeReals) # Note: we don't do a min_data_check for BLD_YRS_FOR_EXISTING_TX, because it may be empty for # models that start with no pre-existing transmission (e.g., island interconnect scenarios). mod.min_data_check('trans_length_km', 'trans_efficiency', 'existing_trans_cap') mod.trans_new_build_allowed = Param(mod.TRANSMISSION_LINES, within=Boolean, default=True) mod.NEW_TRANS_BLD_YRS = Set( dimen=2, initialize=mod.TRANSMISSION_LINES * mod.PERIODS, filter=lambda m, tx, p: m.trans_new_build_allowed[tx]) mod.BLD_YRS_FOR_TX = Set( dimen=2, initialize=lambda m: m.BLD_YRS_FOR_EXISTING_TX | m.NEW_TRANS_BLD_YRS) mod.TX_BUILDS_IN_PERIOD = Set(mod.PERIODS, within=mod.BLD_YRS_FOR_TX, initialize=lambda m, p: set( (tx, bld_yr) for (tx, bld_yr) in m.BLD_YRS_FOR_TX if bld_yr == 'Legacy' or bld_yr <= p)) def bounds_BuildTx(model, tx, bld_yr): if ((tx, bld_yr) in model.BLD_YRS_FOR_EXISTING_TX): return (model.existing_trans_cap[tx], model.existing_trans_cap[tx]) else: return (0, None) mod.BuildTx = Var(mod.BLD_YRS_FOR_TX, within=NonNegativeReals, bounds=bounds_BuildTx) mod.TxCapacityNameplate = Expression( mod.TRANSMISSION_LINES, mod.PERIODS, rule=lambda m, tx, period: sum( m.BuildTx[tx, bld_yr] for (tx2, bld_yr) in m.BLD_YRS_FOR_TX if tx2 == tx and (bld_yr == 'Legacy' or bld_yr <= period))) mod.trans_derating_factor = Param(mod.TRANSMISSION_LINES, within=PercentFraction, default=1) mod.TxCapacityNameplateAvailable = Expression( mod.TRANSMISSION_LINES, mod.PERIODS, rule=lambda m, tx, period: (m.TxCapacityNameplate[tx, period] * m.trans_derating_factor[tx])) mod.trans_terrain_multiplier = Param( mod.TRANSMISSION_LINES, within=Reals, default=1, validate=lambda m, val, tx: val >= 0.5 and val <= 3) mod.trans_capital_cost_per_mw_km = Param(within=NonNegativeReals, default=1000) mod.trans_lifetime_yrs = Param(within=NonNegativeReals, default=20) mod.trans_fixed_om_fraction = Param(within=NonNegativeReals, default=0.03) # Total annual fixed costs for building new transmission lines... # Multiply capital costs by capital recover factor to get annual # payments. Add annual fixed O&M that are expressed as a fraction of # overnight costs. mod.trans_cost_annual = Param( mod.TRANSMISSION_LINES, within=NonNegativeReals, initialize=lambda m, tx: (m.trans_capital_cost_per_mw_km * m.trans_terrain_multiplier[tx] * m. trans_length_km[tx] * (crf(m.interest_rate, m.trans_lifetime_yrs) + m. trans_fixed_om_fraction))) # An expression to summarize annual costs for the objective # function. Units should be total annual future costs in $base_year # real dollars. The objective function will convert these to # base_year Net Present Value in $base_year real dollars. mod.TxFixedCosts = Expression( mod.PERIODS, rule=lambda m, p: sum(m.BuildTx[tx, bld_yr] * m.trans_cost_annual[tx] for (tx, bld_yr) in m.TX_BUILDS_IN_PERIOD[p])) mod.Cost_Components_Per_Period.append('TxFixedCosts') def init_DIRECTIONAL_TX(model): tx_dir = set() for tx in model.TRANSMISSION_LINES: tx_dir.add((model.trans_lz1[tx], model.trans_lz2[tx])) tx_dir.add((model.trans_lz2[tx], model.trans_lz1[tx])) return tx_dir mod.DIRECTIONAL_TX = Set(dimen=2, initialize=init_DIRECTIONAL_TX) mod.TX_CONNECTIONS_TO_ZONE = Set( mod.LOAD_ZONES, initialize=lambda m, lz: set(z for z in m.LOAD_ZONES if (z, lz) in m.DIRECTIONAL_TX)) def init_trans_d_line(m, zone_from, zone_to): for tx in m.TRANSMISSION_LINES: if ((m.trans_lz1[tx] == zone_from and m.trans_lz2[tx] == zone_to) or (m.trans_lz2[tx] == zone_from and m.trans_lz1[tx] == zone_to)): return tx mod.trans_d_line = Param(mod.DIRECTIONAL_TX, within=mod.TRANSMISSION_LINES, initialize=init_trans_d_line)
def define_components(m): # TODO: change this to allow multiple storage technologies. # battery capital cost # TODO: accept a single battery_capital_cost_per_mwh_capacity value or the annual values shown here m.BATTERY_CAPITAL_COST_YEARS = Set() # list of all years for which capital costs are available m.battery_capital_cost_per_mwh_capacity_by_year = Param(m.BATTERY_CAPITAL_COST_YEARS) # TODO: merge this code with batteries.py and auto-select between fixed calendar life and cycle life # based on whether battery_n_years or battery_n_cycles is provided. (Or find some hybrid that can # handle both well?) # number of years the battery can last; we assume there is no limit on cycle life within this period m.battery_n_years = Param() # maximum depth of discharge m.battery_max_discharge = Param() # round-trip efficiency m.battery_efficiency = Param() # fastest time that storage can be emptied (down to max_discharge) m.battery_min_discharge_time = Param() # amount of battery capacity to build and use (in MWh) # TODO: integrate this with other project data, so it can contribute to reserves, etc. m.BuildBattery = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) m.Battery_Capacity = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum( m.BuildBattery[z, bld_yr] for bld_yr in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p] if bld_yr + m.battery_n_years > p ) ) # rate of charging/discharging battery m.ChargeBattery = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.DischargeBattery = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) # storage level at start of each timepoint m.BatteryLevel = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) # add storage dispatch to the zonal energy balance m.Zone_Power_Injections.append('DischargeBattery') m.Zone_Power_Withdrawals.append('ChargeBattery') # add the batteries to the objective function # cost recovery for any battery capacity currently active m.BatteryAnnualCost = Expression( m.PERIODS, rule=lambda m, p: sum( m.BuildBattery[z, bld_yr] * m.battery_capital_cost_per_mwh_capacity_by_year[bld_yr] * crf(m.interest_rate, m.battery_n_years) for bld_yr in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p] if bld_yr + m.battery_n_years > p for z in m.LOAD_ZONES ) ) m.Cost_Components_Per_Period.append('BatteryAnnualCost') # Calculate the state of charge based on conservation of energy # NOTE: this is circular for each day # NOTE: the overall level for the day is free, but the levels each timepoint are chained. m.Battery_Level_Calc = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.BatteryLevel[z, t] == m.BatteryLevel[z, m.tp_previous[t]] + m.tp_duration_hrs[t] * ( m.battery_efficiency * m.ChargeBattery[z, m.tp_previous[t]] - m.DischargeBattery[z, m.tp_previous[t]] ) ) # limits on storage level m.Battery_Min_Level = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: (1.0 - m.battery_max_discharge) * m.Battery_Capacity[z, m.tp_period[t]] <= m.BatteryLevel[z, t] ) m.Battery_Max_Level = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.BatteryLevel[z, t] <= m.Battery_Capacity[z, m.tp_period[t]] ) m.Battery_Max_Charge_Rate = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.ChargeBattery[z, t] <= # changed 2018-02-20 to allow full discharge in min_discharge_time, # (previously pegged to battery_max_discharge) m.Battery_Capacity[z, m.tp_period[t]] / m.battery_min_discharge_time ) m.Battery_Max_Discharge_Rate = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.DischargeBattery[z, t] <= m.Battery_Capacity[z, m.tp_period[t]] / m.battery_min_discharge_time ) # how much could output/input be increased on short notice (to provide reserves) m.BatterySlackUp = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.Battery_Capacity[z, m.tp_period[t]] / m.battery_min_discharge_time - m.DischargeBattery[z, t] + m.ChargeBattery[z, t] ) m.BatterySlackDown = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.Battery_Capacity[z, m.tp_period[t]] / m.battery_min_discharge_time - m.ChargeBattery[z, t] + m.DischargeBattery[z, t] ) # assume batteries can only complete one full cycle per day, averaged over each period # (this was pegged to battery_max_discharge before 2018-02-20) m.Battery_Cycle_Limit = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.DischargeBattery[z, tp] * m.tp_duration_hrs[tp] for tp in m.TPS_IN_PERIOD[p]) <= m.Battery_Capacity[z, p] * m.period_length_hours[p] ) # Register with spinning reserves if it is available if 'Spinning_Reserve_Up_Provisions' in dir(m): m.BatterySpinningReserveUp = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: sum(m.BatterySlackUp[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) m.Spinning_Reserve_Up_Provisions.append('BatterySpinningReserveUp') m.BatterySpinningReserveDown = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: \ sum(m.BatterySlackDown[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) m.Spinning_Reserve_Down_Provisions.append('BatterySpinningReserveDown')
def define_components(m): m.PH_GENS = Set() m.ph_load_zone = Param(m.PH_GENS) m.ph_capital_cost_per_mw = Param(m.PH_GENS, within=NonNegativeReals) m.ph_project_life = Param(m.PH_GENS, within=NonNegativeReals) # annual O&M cost for pumped hydro project, percent of capital cost m.ph_fixed_om_percent = Param(m.PH_GENS, within=NonNegativeReals) # total annual cost m.ph_fixed_cost_per_mw_per_year = Param(m.PH_GENS, initialize=lambda m, p: m.ph_capital_cost_per_mw[p] * (crf(m.interest_rate, m.ph_project_life[p]) + m.ph_fixed_om_percent[p]) ) # round-trip efficiency of the pumped hydro facility m.ph_efficiency = Param(m.PH_GENS) # average energy available from water inflow each day # (system must balance energy net of this each day) m.ph_inflow_mw = Param(m.PH_GENS) # maximum size of pumped hydro project m.ph_max_capacity_mw = Param(m.PH_GENS) # How much pumped hydro to build m.BuildPumpedHydroMW = Var(m.PH_GENS, m.PERIODS, within=NonNegativeReals) m.Pumped_Hydro_Proj_Capacity_MW = Expression(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: sum(m.BuildPumpedHydroMW[g, pp] for pp in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[pe]) ) # flag indicating whether any capacity is added to each project each year m.BuildAnyPumpedHydro = Var(m.PH_GENS, m.PERIODS, within=Binary) # How to run pumped hydro m.PumpedHydroProjGenerateMW = Var(m.PH_GENS, m.TIMEPOINTS, within=NonNegativeReals) m.PumpedHydroProjStoreMW = Var(m.PH_GENS, m.TIMEPOINTS, within=NonNegativeReals) # constraints on construction of pumped hydro # don't build more than the max allowed capacity m.Pumped_Hydro_Max_Build = Constraint(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.Pumped_Hydro_Proj_Capacity_MW[g, pe] <= m.ph_max_capacity_mw[g] ) # force the build flag on for the year(s) when pumped hydro is built m.Pumped_Hydro_Set_Build_Flag = Constraint(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.BuildPumpedHydroMW[g, pe] <= m.BuildAnyPumpedHydro[g, pe] * m.ph_max_capacity_mw[g] ) # only build in one year (can be deactivated to allow incremental construction) m.Pumped_Hydro_Build_Once = Constraint(m.PH_GENS, rule=lambda m, g: sum(m.BuildAnyPumpedHydro[g, pe] for pe in m.PERIODS) <= 1) # only build full project size (deactivated by default, to allow smaller projects) m.Pumped_Hydro_Build_All_Or_None = Constraint(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.BuildPumpedHydroMW[g, pe] == m.BuildAnyPumpedHydro[g, pe] * m.ph_max_capacity_mw[g] ) # m.Deactivate_Pumped_Hydro_Build_All_Or_None = BuildAction(rule=lambda m: # m.Pumped_Hydro_Build_All_Or_None.deactivate() # ) # limits on pumping and generation m.Pumped_Hydro_Max_Generate_Rate = Constraint(m.PH_GENS, m.TIMEPOINTS, rule=lambda m, g, t: m.PumpedHydroProjGenerateMW[g, t] <= m.Pumped_Hydro_Proj_Capacity_MW[g, m.tp_period[t]] ) m.Pumped_Hydro_Max_Store_Rate = Constraint(m.PH_GENS, m.TIMEPOINTS, rule=lambda m, g, t: m.PumpedHydroProjStoreMW[g, t] <= m.Pumped_Hydro_Proj_Capacity_MW[g, m.tp_period[t]] ) # return reservoir to at least the starting level every day, net of any inflow # it can also go higher than starting level, which indicates spilling surplus water m.Pumped_Hydro_Daily_Balance = Constraint(m.PH_GENS, m.TIMESERIES, rule=lambda m, g, ts: sum( m.PumpedHydroProjStoreMW[g, tp] * m.ph_efficiency[g] + m.ph_inflow_mw[g] - m.PumpedHydroProjGenerateMW[g, tp] for tp in m.TPS_IN_TS[ts] ) >= 0 ) m.GeneratePumpedHydro = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: sum(m.PumpedHydroProjGenerateMW[g, t] for g in m.PH_GENS if m.ph_load_zone[g]==z) ) m.StorePumpedHydro = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: sum(m.PumpedHydroProjStoreMW[g, t] for g in m.PH_GENS if m.ph_load_zone[g]==z) ) # calculate costs m.Pumped_Hydro_Fixed_Cost_Annual = Expression(m.PERIODS, rule=lambda m, pe: sum(m.ph_fixed_cost_per_mw_per_year[g] * m.Pumped_Hydro_Proj_Capacity_MW[g, pe] for g in m.PH_GENS) ) m.Cost_Components_Per_Period.append('Pumped_Hydro_Fixed_Cost_Annual') # add pumped hydro to zonal energy balance m.Zone_Power_Injections.append('GeneratePumpedHydro') m.Zone_Power_Withdrawals.append('StorePumpedHydro') # total pumped hydro capacity in each zone each period (for reporting) m.Pumped_Hydro_Capacity_MW = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, pe: sum(m.Pumped_Hydro_Proj_Capacity_MW[g, pe] for g in m.PH_GENS if m.ph_load_zone[g]==z) ) # force construction of a fixed amount of pumped hydro if m.options.ph_mw is not None: print("Forcing construction of {m} MW of pumped hydro.".format(m=m.options.ph_mw)) m.Build_Pumped_Hydro_MW = Constraint(m.LOAD_ZONES, rule=lambda m, z: m.Pumped_Hydro_Capacity_MW[z, m.PERIODS.last()] == m.options.ph_mw ) # force construction of pumped hydro only in a certain period if m.options.ph_year is not None: print("Allowing construction of pumped hydro only in {p}.".format(p=m.options.ph_year)) m.Build_Pumped_Hydro_Year = Constraint( m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.BuildPumpedHydroMW[g, pe] == 0.0 if pe != m.options.ph_year else Constraint.Skip )
def define_components(mod): """ Adds components to a Pyomo abstract model object to describe bulk transmission of an electric grid. This includes parameters, build decisions and constraints. Unless otherwise stated, all power capacity is specified in units of MW and all sets and parameters are mandatory. TRANSMISSION_LINES is the complete set of transmission pathways connecting load zones. Each member of this set is a one dimensional identifier such as "A-B". This set has no regard for directionality of transmission lines and will generate an error if you specify two lines that move in opposite directions such as (A to B) and (B to A). Another derived set - TRANS_LINES_DIRECTIONAL - stores directional information. Transmission may be abbreviated as trans or tx in parameter names or indexes. trans_lz1[tx] and trans_lz2[tx] specify the load zones at either end of a transmission line. The order of 1 and 2 is unimportant, but you are encouraged to be consistent to simplify merging information back into external databases. trans_dbid[tx in TRANSMISSION_LINES] is an external database identifier for each transmission line. This is an optional parameter than defaults to the identifier of the transmission line. trans_length_km[tx in TRANSMISSION_LINES] is the length of each transmission line in kilometers. trans_efficiency[tx in TRANSMISSION_LINES] is the proportion of energy sent down a line that is delivered. If 2 percent of energy sent down a line is lost, this value would be set to 0.98. trans_new_build_allowed[tx in TRANSMISSION_LINES] is a binary value indicating whether new transmission build-outs are allowed along a transmission line. This optional parameter defaults to True. BLD_YRS_FOR_TX is the set of transmission lines and years in which they have been or could be built. This set includes past and potential future builds. All future builds must come online in the first year of an investment period. This set is composed of two elements with members: (tx, build_year). For existing transmission where the build years are not known, build_year is set to 'Legacy'. BLD_YRS_FOR_EXISTING_TX is a subset of BLD_YRS_FOR_TX that lists builds that happened before the first investment period. For most datasets the build year is unknown, so is it always set to 'Legacy'. existing_trans_cap[tx in TRANSMISSION_LINES] is a parameter that describes how many MW of capacity has been installed before the start of the study. NEW_TRANS_BLD_YRS is a subset of BLD_YRS_FOR_TX that describes potential builds. BuildTx[(tx, bld_yr) in BLD_YRS_FOR_TX] is a decision variable that describes the transfer capacity in MW installed on a corridor in a given build year. For existing builds, this variable is locked to the existing capacity. TxCapacityNameplate[(tx, bld_yr) in BLD_YRS_FOR_TX] is an expression that returns the total nameplate transfer capacity of a transmission line in a given period. This is the sum of existing and newly-build capacity. trans_derating_factor[tx in TRANSMISSION_LINES] is an overall derating factor for each transmission line that can reflect forced outage rates, stability or contingency limitations. This parameter is optional and defaults to 1. This parameter should be in the range of 0 to 1, being 0 a value that disables the line completely. TxCapacityNameplateAvailable[(tx, bld_yr) in BLD_YRS_FOR_TX] is an expression that returns the available transfer capacity of a transmission line in a given period, taking into account the nameplate capacity and derating factor. trans_terrain_multiplier[tx in TRANSMISSION_LINES] is a cost adjuster applied to each transmission line that reflects the additional costs that may be incurred for traversing that specific terrain. Crossing mountains or cities will be more expensive than crossing plains. This parameter is optional and defaults to 1. This parameter should be in the range of 0.5 to 3. trans_capital_cost_per_mw_km describes the generic costs of building new transmission in units of $BASE_YEAR per MW transfer capacity per km. This is optional and defaults to 1000. trans_lifetime_yrs is the number of years in which a capital construction loan for a new transmission line is repaid. This optional parameter defaults to 20 years based on 2009 WREZ transmission model transmission data. At the end of this time, we assume transmission lines will be rebuilt at the same cost. trans_fixed_o_m_fraction is describes the fixed Operations and Maintenance costs as a fraction of capital costs. This optional parameter defaults to 0.03 based on 2009 WREZ transmission model transmission data costs for existing transmission maintenance. trans_cost_hourly[tx TRANSMISSION_LINES] is the cost of building transmission lines in units of $BASE_YEAR / MW- transfer-capacity / hour. This derived parameter is based on the total annualized capital and fixed O&M costs, then divides that by hours per year to determine the portion of costs incurred hourly. DIRECTIONAL_TX is a derived set of directional paths that electricity can flow along transmission lines. Each element of this set is a two-dimensional entry that describes the origin and destination of the flow: (load_zone_from, load_zone_to). Every transmission line will generate two entries in this set. Members of this set are abbreviated as trans_d where possible, but may be abbreviated as tx in situations where brevity is important and it is unlikely to be confused with the overall transmission line. trans_d_line[trans_d] is the transmission line associated with this directional path. TX_BUILDS_IN_PERIOD[p in PERIODS] is an indexed set that describes which transmission builds will be operational in a given period. Currently, transmission lines are kept online indefinitely, with parts being replaced as they wear out. TX_BUILDS_IN_PERIOD[p] will return a subset of (tx, bld_yr) in BLD_YRS_FOR_TX. --- Delayed implementation --- is_dc_line ... Do I even need to implement this? --- NOTES --- The cost stream over time for transmission lines differs from the SWITCH-WECC model. The SWITCH-WECC model assumed new transmission had a financial lifetime of 20 years, which was the length of the loan term. During this time, fixed operations & maintenance costs were also incurred annually and these were estimated to be 3 percent of the initial capital costs. These fixed O&M costs were obtained from the 2009 WREZ transmission model transmission data costs for existing transmission maintenance .. most of those lines were old and their capital loans had been paid off, so the O&M were the costs of keeping them operational. SWITCH-WECC basically assumed the lines could be kept online indefinitely with that O&M budget, with components of the lines being replaced as needed. This payment schedule and lifetimes was assumed to hold for both existing and new lines. This made the annual costs change over time, which could create edge effects near the end of the study period. SWITCH-WECC had different cost assumptions for local T&D; capital expenses and fixed O&M expenses were rolled in together, and those were assumed to continue indefinitely. This basically assumed that local T&D would be replaced at the end of its financial lifetime. SWITCH-Pyomo treats all transmission and distribution (long- distance or local) the same. Any capacity that is built will be kept online indefinitely. At the end of its financial lifetime, existing capacity will be retired and rebuilt, so the annual cost of a line upgrade will remain constant in every future year. """ mod.TRANSMISSION_LINES = Set() mod.trans_lz1 = Param(mod.TRANSMISSION_LINES, within=mod.LOAD_ZONES) mod.trans_lz2 = Param(mod.TRANSMISSION_LINES, within=mod.LOAD_ZONES) mod.min_data_check('TRANSMISSION_LINES', 'trans_lz1', 'trans_lz2') mod.trans_dbid = Param(mod.TRANSMISSION_LINES, default=lambda m, tx: tx) mod.trans_length_km = Param(mod.TRANSMISSION_LINES, within=PositiveReals) mod.trans_efficiency = Param( mod.TRANSMISSION_LINES, within=PositiveReals, validate=lambda m, val, tx: val <= 1) mod.BLD_YRS_FOR_EXISTING_TX = Set( dimen=2, initialize=lambda m: set( (tx, 'Legacy') for tx in m.TRANSMISSION_LINES)) mod.existing_trans_cap = Param( mod.TRANSMISSION_LINES, within=NonNegativeReals) mod.min_data_check( 'trans_length_km', 'trans_efficiency', 'BLD_YRS_FOR_EXISTING_TX', 'existing_trans_cap') mod.trans_new_build_allowed = Param( mod.TRANSMISSION_LINES, within=Boolean, default=True) mod.NEW_TRANS_BLD_YRS = Set( dimen=2, initialize=mod.TRANSMISSION_LINES * mod.PERIODS, filter=lambda m, tx, p: m.trans_new_build_allowed[tx]) mod.BLD_YRS_FOR_TX = Set( dimen=2, initialize=lambda m: m.BLD_YRS_FOR_EXISTING_TX | m.NEW_TRANS_BLD_YRS) mod.TX_BUILDS_IN_PERIOD = Set( mod.PERIODS, within=mod.BLD_YRS_FOR_TX, initialize=lambda m, p: set( (tx, bld_yr) for (tx, bld_yr) in m.BLD_YRS_FOR_TX if bld_yr == 'Legacy' or bld_yr <= p)) def bounds_BuildTx(model, tx, bld_yr): if((tx, bld_yr) in model.BLD_YRS_FOR_EXISTING_TX): return (model.existing_trans_cap[tx], model.existing_trans_cap[tx]) else: return (0, None) mod.BuildTx = Var( mod.BLD_YRS_FOR_TX, within=NonNegativeReals, bounds=bounds_BuildTx) mod.TxCapacityNameplate = Expression( mod.TRANSMISSION_LINES, mod.PERIODS, rule=lambda m, tx, period: sum( m.BuildTx[tx, bld_yr] for (tx2, bld_yr) in m.BLD_YRS_FOR_TX if tx2 == tx and (bld_yr == 'Legacy' or bld_yr <= period))) mod.trans_derating_factor = Param( mod.TRANSMISSION_LINES, within=NonNegativeReals, default=1, validate=lambda m, val, tx: val <= 1) mod.TxCapacityNameplateAvailable = Expression( mod.TRANSMISSION_LINES, mod.PERIODS, rule=lambda m, tx, period: ( m.TxCapacityNameplate[tx, period] * m.trans_derating_factor[tx])) mod.trans_terrain_multiplier = Param( mod.TRANSMISSION_LINES, within=Reals, default=1, validate=lambda m, val, tx: val >= 0.5 and val <= 3) mod.trans_capital_cost_per_mw_km = Param( within=PositiveReals, default=1000) mod.trans_lifetime_yrs = Param( within=PositiveReals, default=20) mod.trans_fixed_o_m_fraction = Param( within=PositiveReals, default=0.03) # Total annual fixed costs for building new transmission lines... # Multiply capital costs by capital recover factor to get annual # payments. Add annual fixed O&M that are expressed as a fraction of # overnight costs. mod.trans_cost_annual = Param( mod.TRANSMISSION_LINES, within=PositiveReals, initialize=lambda m, tx: ( m.trans_capital_cost_per_mw_km * m.trans_terrain_multiplier[tx] * m.trans_length_km[tx] * (crf(m.interest_rate, m.trans_lifetime_yrs) + m.trans_fixed_o_m_fraction))) # An expression to summarize annual costs for the objective # function. Units should be total annual future costs in $base_year # real dollars. The objective function will convert these to # base_year Net Present Value in $base_year real dollars. mod.TxFixedCosts = Expression( mod.PERIODS, rule=lambda m, p: sum( m.BuildTx[tx, bld_yr] * m.trans_cost_annual[tx] for (tx, bld_yr) in m.TX_BUILDS_IN_PERIOD[p])) mod.Cost_Components_Per_Period.append('TxFixedCosts') def init_DIRECTIONAL_TX(model): tx_dir = set() for tx in model.TRANSMISSION_LINES: tx_dir.add((model.trans_lz1[tx], model.trans_lz2[tx])) tx_dir.add((model.trans_lz2[tx], model.trans_lz1[tx])) return tx_dir mod.DIRECTIONAL_TX = Set( dimen=2, initialize=init_DIRECTIONAL_TX) mod.TX_CONNECTIONS_TO_ZONE = Set( mod.LOAD_ZONES, initialize=lambda m, lz: set( z for z in m.LOAD_ZONES if (z,lz) in m.DIRECTIONAL_TX)) def init_trans_d_line(m, zone_from, zone_to): for tx in m.TRANSMISSION_LINES: if((m.trans_lz1[tx] == zone_from and m.trans_lz2[tx] == zone_to) or (m.trans_lz2[tx] == zone_from and m.trans_lz1[tx] == zone_to)): return tx mod.trans_d_line = Param( mod.DIRECTIONAL_TX, within=mod.TRANSMISSION_LINES, initialize=init_trans_d_line)
def define_components(m): m.PH_GENS = Set() m.ph_load_zone = Param(m.PH_GENS) m.ph_capital_cost_per_mw = Param(m.PH_GENS, within=NonNegativeReals) m.ph_gect_life = Param(m.PH_GENS, within=NonNegativeReals) # annual O&M cost for pumped hydro project, percent of capital cost m.ph_fixed_om_percent = Param(m.PH_GENS, within=NonNegativeReals) # total annual cost m.ph_fixed_cost_per_mw_per_year = Param(m.PH_GENS, initialize=lambda m, p: m.ph_capital_cost_per_mw[p] * (crf(m.interest_rate, m.ph_gect_life[p]) + m.ph_fixed_om_percent[p]) ) # round-trip efficiency of the pumped hydro facility m.ph_efficiency = Param(m.PH_GENS) # average energy available from water inflow each day # (system must balance energy net of this each day) m.ph_inflow_mw = Param(m.PH_GENS) # maximum size of pumped hydro project m.ph_max_capacity_mw = Param(m.PH_GENS) # How much pumped hydro to build m.BuildPumpedHydroMW = Var(m.PH_GENS, m.PERIODS, within=NonNegativeReals) m.Pumped_Hydro_Proj_Capacity_MW = Expression(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: sum(m.BuildPumpedHydroMW[g, pp] for pp in m.CURRENT_AND_PRIOR_PERIODS[pe]) ) # flag indicating whether any capacity is added to each project each year m.BuildAnyPumpedHydro = Var(m.PH_GENS, m.PERIODS, within=Binary) # How to run pumped hydro m.PumpedHydroProjGenerateMW = Var(m.PH_GENS, m.TIMEPOINTS, within=NonNegativeReals) m.PumpedHydroProjStoreMW = Var(m.PH_GENS, m.TIMEPOINTS, within=NonNegativeReals) # constraints on construction of pumped hydro # don't build more than the max allowed capacity m.Pumped_Hydro_Max_Build = Constraint(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.Pumped_Hydro_Proj_Capacity_MW[g, pe] <= m.ph_max_capacity_mw[g] ) # force the build flag on for the year(s) when pumped hydro is built m.Pumped_Hydro_Set_Build_Flag = Constraint(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.BuildPumpedHydroMW[g, pe] <= m.BuildAnyPumpedHydro[g, pe] * m.ph_max_capacity_mw[g] ) # only build in one year (can be deactivated to allow incremental construction) m.Pumped_Hydro_Build_Once = Constraint(m.PH_GENS, rule=lambda m, g: sum(m.BuildAnyPumpedHydro[g, pe] for pe in m.PERIODS) <= 1) # only build full project size (deactivated by default, to allow smaller projects) m.Pumped_Hydro_Build_All_Or_None = Constraint(m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.BuildPumpedHydroMW[g, pe] == m.BuildAnyPumpedHydro[g, pe] * m.ph_max_capacity_mw[g] ) # m.Deactivate_Pumped_Hydro_Build_All_Or_None = BuildAction(rule=lambda m: # m.Pumped_Hydro_Build_All_Or_None.deactivate() # ) # limits on pumping and generation m.Pumped_Hydro_Max_Generate_Rate = Constraint(m.PH_GENS, m.TIMEPOINTS, rule=lambda m, g, t: m.PumpedHydroProjGenerateMW[g, t] <= m.Pumped_Hydro_Proj_Capacity_MW[g, m.tp_period[t]] ) m.Pumped_Hydro_Max_Store_Rate = Constraint(m.PH_GENS, m.TIMEPOINTS, rule=lambda m, g, t: m.PumpedHydroProjStoreMW[g, t] <= m.Pumped_Hydro_Proj_Capacity_MW[g, m.tp_period[t]] ) # return reservoir to at least the starting level every day, net of any inflow # it can also go higher than starting level, which indicates spilling surplus water m.Pumped_Hydro_Daily_Balance = Constraint(m.PH_GENS, m.TIMESERIES, rule=lambda m, g, ts: sum( m.PumpedHydroProjStoreMW[g, tp] * m.ph_efficiency[g] + m.ph_inflow_mw[g] - m.PumpedHydroProjGenerateMW[g, tp] for tp in m.TPS_IN_TS[ts] ) >= 0 ) m.GeneratePumpedHydro = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: sum(m.PumpedHydroProjGenerateMW[g, t] for g in m.PH_GENS if m.ph_load_zone[g]==z) ) m.StorePumpedHydro = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: sum(m.PumpedHydroProjStoreMW[g, t] for g in m.PH_GENS if m.ph_load_zone[g]==z) ) # calculate costs m.Pumped_Hydro_Fixed_Cost_Annual = Expression(m.PERIODS, rule=lambda m, pe: sum(m.ph_fixed_cost_per_mw_per_year[g] * m.Pumped_Hydro_Proj_Capacity_MW[g, pe] for g in m.PH_GENS) ) m.Cost_Components_Per_Period.append('Pumped_Hydro_Fixed_Cost_Annual') # add pumped hydro to zonal energy balance m.Zone_Power_Injections.append('GeneratePumpedHydro') m.Zone_Power_Withdrawals.append('StorePumpedHydro') # total pumped hydro capacity in each zone each period (for reporting) m.Pumped_Hydro_Capacity_MW = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, pe: sum(m.Pumped_Hydro_Proj_Capacity_MW[g, pe] for g in m.PH_GENS if m.ph_load_zone[g]==z) ) # force construction of a fixed amount of pumped hydro if m.options.ph_mw is not None: print "Forcing construction of {m} MW of pumped hydro.".format(m=m.options.ph_mw) m.Build_Pumped_Hydro_MW = Constraint(m.LOAD_ZONES, rule=lambda m, z: m.Pumped_Hydro_Capacity_MW[z, m.PERIODS.last()] == m.options.ph_mw ) # force construction of pumped hydro only in a certain period if m.options.ph_year is not None: print "Allowing construction of pumped hydro only in {p}.".format(p=m.options.ph_year) m.Build_Pumped_Hydro_Year = Constraint( m.PH_GENS, m.PERIODS, rule=lambda m, g, pe: m.BuildPumpedHydroMW[g, pe] == 0.0 if pe != m.options.ph_year else Constraint.Skip )
def define_hydrogen_components(m): # electrolyzer details m.hydrogen_electrolyzer_capital_cost_per_mw = Param() m.hydrogen_electrolyzer_fixed_cost_per_mw_year = Param(default=0.0) m.hydrogen_electrolyzer_variable_cost_per_kg = Param(default=0.0) # assumed to include any refurbishment needed m.hydrogen_electrolyzer_kg_per_mwh = Param() # assumed to deliver H2 at enough pressure for liquifier and daily buffering m.hydrogen_electrolyzer_life_years = Param() m.BuildElectrolyzerMW = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) m.ElectrolyzerCapacityMW = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildElectrolyzerMW[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p])) m.RunElectrolyzerMW = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.ProduceHydrogenKgPerHour = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.RunElectrolyzerMW[z, t] * m.hydrogen_electrolyzer_kg_per_mwh) # note: we assume there is a gaseous hydrogen storage tank that is big enough to buffer # daily production, storage and withdrawals of hydrogen, but we don't include a cost # for this (because it will be negligible compared to the rest of the costs) # This allows the system to do some intra-day arbitrage without going all the way to liquification # liquifier details m.hydrogen_liquifier_capital_cost_per_kg_per_hour = Param() m.hydrogen_liquifier_fixed_cost_per_kg_hour_year = Param(default=0.0) m.hydrogen_liquifier_variable_cost_per_kg = Param(default=0.0) m.hydrogen_liquifier_mwh_per_kg = Param() m.hydrogen_liquifier_life_years = Param() m.BuildLiquifierKgPerHour = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) # capacity to build, measured in kg/hour of throughput m.LiquifierCapacityKgPerHour = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildLiquifierKgPerHour[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p])) m.LiquifyHydrogenKgPerHour = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.LiquifyHydrogenMW = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.LiquifyHydrogenKgPerHour[z, t] * m.hydrogen_liquifier_mwh_per_kg ) # storage tank details m.liquid_hydrogen_tank_capital_cost_per_kg = Param() m.liquid_hydrogen_tank_minimum_size_kg = Param(default=0.0) m.liquid_hydrogen_tank_life_years = Param() m.BuildLiquidHydrogenTankKg = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) # in kg m.LiquidHydrogenTankCapacityKg = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildLiquidHydrogenTankKg[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p])) m.StoreLiquidHydrogenKg = Expression(m.LOAD_ZONES, m.TIMESERIES, rule=lambda m, z, ts: m.ts_duration_of_tp[ts] * sum(m.LiquifyHydrogenKgPerHour[z, tp] for tp in m.TPS_IN_TS[ts]) ) m.WithdrawLiquidHydrogenKg = Var(m.LOAD_ZONES, m.TIMESERIES, within=NonNegativeReals) # note: we assume the system will be large enough to neglect boil-off # fuel cell details m.hydrogen_fuel_cell_capital_cost_per_mw = Param() m.hydrogen_fuel_cell_fixed_cost_per_mw_year = Param(default=0.0) m.hydrogen_fuel_cell_variable_cost_per_mwh = Param(default=0.0) # assumed to include any refurbishment needed m.hydrogen_fuel_cell_mwh_per_kg = Param() m.hydrogen_fuel_cell_life_years = Param() m.BuildFuelCellMW = Var(m.LOAD_ZONES, m.PERIODS, within=NonNegativeReals) m.FuelCellCapacityMW = Expression(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.BuildFuelCellMW[z, p_] for p_ in m.CURRENT_AND_PRIOR_PERIODS_FOR_PERIOD[p])) m.DispatchFuelCellMW = Var(m.LOAD_ZONES, m.TIMEPOINTS, within=NonNegativeReals) m.ConsumeHydrogenKgPerHour = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.DispatchFuelCellMW[z, t] / m.hydrogen_fuel_cell_mwh_per_kg ) # hydrogen mass balances # note: this allows for buffering of same-day production and consumption # of hydrogen without ever liquifying it m.Hydrogen_Conservation_of_Mass_Daily = Constraint(m.LOAD_ZONES, m.TIMESERIES, rule=lambda m, z, ts: m.StoreLiquidHydrogenKg[z, ts] - m.WithdrawLiquidHydrogenKg[z, ts] == m.ts_duration_of_tp[ts] * sum( m.ProduceHydrogenKgPerHour[z, tp] - m.ConsumeHydrogenKgPerHour[z, tp] for tp in m.TPS_IN_TS[ts] ) ) m.Hydrogen_Conservation_of_Mass_Annual = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum( (m.StoreLiquidHydrogenKg[z, ts] - m.WithdrawLiquidHydrogenKg[z, ts]) * m.ts_scale_to_year[ts] for ts in m.TS_IN_PERIOD[p] ) == 0 ) # limits on equipment m.Max_Run_Electrolyzer = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.RunElectrolyzerMW[z, t] <= m.ElectrolyzerCapacityMW[z, m.tp_period[t]]) m.Max_Run_Fuel_Cell = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.DispatchFuelCellMW[z, t] <= m.FuelCellCapacityMW[z, m.tp_period[t]]) m.Max_Run_Liquifier = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.LiquifyHydrogenKgPerHour[z, t] <= m.LiquifierCapacityKgPerHour[z, m.tp_period[t]]) # minimum size for hydrogen tank m.BuildAnyLiquidHydrogenTank = Var(m.LOAD_ZONES, m.PERIODS, within=Binary) m.Set_BuildAnyLiquidHydrogenTank_Flag = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: Constraint.Skip if m.liquid_hydrogen_tank_minimum_size_kg == 0.0 else ( m.BuildLiquidHydrogenTankKg[z, p] <= 1000 * m.BuildAnyLiquidHydrogenTank[z, p] * m.liquid_hydrogen_tank_minimum_size_kg ) ) m.Build_Minimum_Liquid_Hydrogen_Tank = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: Constraint.Skip if m.liquid_hydrogen_tank_minimum_size_kg == 0.0 else ( m.BuildLiquidHydrogenTankKg[z, p] >= m.BuildAnyLiquidHydrogenTank[z, p] * m.liquid_hydrogen_tank_minimum_size_kg ) ) # maximum amount that hydrogen fuel cells can contribute to system reserves # Note: we assume we can't use fuel cells for reserves unless we've also built at least half # as much electrolyzer capacity and a tank that can provide the reserves for 12 hours # (this is pretty arbitrary, but avoids just installing a fuel cell as a "free" source of reserves) m.HydrogenFuelCellMaxReservePower = Var(m.LOAD_ZONES, m.TIMEPOINTS) m.Hydrogen_FC_Reserve_Capacity_Limit = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.HydrogenFuelCellMaxReservePower[z, t] <= m.FuelCellCapacityMW[z, m.tp_period[t]] ) m.Hydrogen_FC_Reserve_Storage_Limit = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.HydrogenFuelCellMaxReservePower[z, t] <= m.LiquidHydrogenTankCapacityKg[z, m.tp_period[t]] * m.hydrogen_fuel_cell_mwh_per_kg / 12.0 ) m.Hydrogen_FC_Reserve_Electrolyzer_Limit = Constraint(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.HydrogenFuelCellMaxReservePower[z, t] <= 2.0 * m.ElectrolyzerCapacityMW[z, m.tp_period[t]] ) # how much extra power could hydrogen equipment produce or absorb on short notice (for reserves) m.HydrogenSlackUp = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.RunElectrolyzerMW[z, t] + m.LiquifyHydrogenMW[z, t] + m.HydrogenFuelCellMaxReservePower[z, t] - m.DispatchFuelCellMW[z, t] ) m.HydrogenSlackDown = Expression(m.LOAD_ZONES, m.TIMEPOINTS, rule=lambda m, z, t: m.ElectrolyzerCapacityMW[z, m.tp_period[t]] - m.RunElectrolyzerMW[z, t] # ignore liquifier potential since it's small and this is a low-value reserve product + m.DispatchFuelCellMW[z, t] ) # there must be enough storage to hold _all_ the production each period (net of same-day consumption) # note: this assumes we cycle the system only once per year (store all energy, then release all energy) # alternatives: allow monthly or seasonal cycling, or directly model the whole year with inter-day linkages m.Max_Store_Liquid_Hydrogen = Constraint(m.LOAD_ZONES, m.PERIODS, rule=lambda m, z, p: sum(m.StoreLiquidHydrogenKg[z, ts] * m.ts_scale_to_year[ts] for ts in m.TS_IN_PERIOD[p]) <= m.LiquidHydrogenTankCapacityKg[z, p] ) # add electricity consumption and production to the zonal energy balance m.Zone_Power_Withdrawals.append('RunElectrolyzerMW') m.Zone_Power_Withdrawals.append('LiquifyHydrogenMW') m.Zone_Power_Injections.append('DispatchFuelCellMW') # add costs to the model m.HydrogenVariableCost = Expression(m.TIMEPOINTS, rule=lambda m, t: sum( m.ProduceHydrogenKgPerHour[z, t] * m.hydrogen_electrolyzer_variable_cost_per_kg + m.LiquifyHydrogenKgPerHour[z, t] * m.hydrogen_liquifier_variable_cost_per_kg + m.DispatchFuelCellMW[z, t] * m.hydrogen_fuel_cell_variable_cost_per_mwh for z in m.LOAD_ZONES ) ) m.HydrogenFixedCostAnnual = Expression(m.PERIODS, rule=lambda m, p: sum( m.ElectrolyzerCapacityMW[z, p] * ( m.hydrogen_electrolyzer_capital_cost_per_mw * crf(m.interest_rate, m.hydrogen_electrolyzer_life_years) + m.hydrogen_electrolyzer_fixed_cost_per_mw_year) + m.LiquifierCapacityKgPerHour[z, p] * ( m.hydrogen_liquifier_capital_cost_per_kg_per_hour * crf(m.interest_rate, m.hydrogen_liquifier_life_years) + m.hydrogen_liquifier_fixed_cost_per_kg_hour_year) + m.LiquidHydrogenTankCapacityKg[z, p] * ( m.liquid_hydrogen_tank_capital_cost_per_kg * crf(m.interest_rate, m.liquid_hydrogen_tank_life_years)) + m.FuelCellCapacityMW[z, p] * ( m.hydrogen_fuel_cell_capital_cost_per_mw * crf(m.interest_rate, m.hydrogen_fuel_cell_life_years) + m.hydrogen_fuel_cell_fixed_cost_per_mw_year) for z in m.LOAD_ZONES ) ) m.Cost_Components_Per_TP.append('HydrogenVariableCost') m.Cost_Components_Per_Period.append('HydrogenFixedCostAnnual') # Register with spinning reserves if it is available if [rt.lower() for rt in m.options.hydrogen_reserve_types] != ['none']: # Register with spinning reserves if hasattr(m, 'Spinning_Reserve_Up_Provisions'): # calculate available slack from hydrogen equipment m.HydrogenSlackUpForArea = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: sum(m.HydrogenSlackUp[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) m.HydrogenSlackDownForArea = Expression( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, b, t: sum(m.HydrogenSlackDown[z, t] for z in m.ZONES_IN_BALANCING_AREA[b]) ) if hasattr(m, 'GEN_SPINNING_RESERVE_TYPES'): # using advanced formulation, index by reserve type, balancing area, timepoint # define variables for each type of reserves to be provided # choose how to allocate the slack between the different reserve products m.HYDROGEN_SPINNING_RESERVE_TYPES = Set( initialize=m.options.hydrogen_reserve_types ) m.HydrogenSpinningReserveUp = Var( m.HYDROGEN_SPINNING_RESERVE_TYPES, m.BALANCING_AREA_TIMEPOINTS, within=NonNegativeReals ) m.HydrogenSpinningReserveDown = Var( m.HYDROGEN_SPINNING_RESERVE_TYPES, m.BALANCING_AREA_TIMEPOINTS, within=NonNegativeReals ) # constrain reserve provision within available slack m.Limit_HydrogenSpinningReserveUp = Constraint( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, ba, tp: sum( m.HydrogenSpinningReserveUp[rt, ba, tp] for rt in m.HYDROGEN_SPINNING_RESERVE_TYPES ) <= m.HydrogenSlackUpForArea[ba, tp] ) m.Limit_HydrogenSpinningReserveDown = Constraint( m.BALANCING_AREA_TIMEPOINTS, rule=lambda m, ba, tp: sum( m.HydrogenSpinningReserveDown[rt, ba, tp] for rt in m.HYDROGEN_SPINNING_RESERVE_TYPES ) <= m.HydrogenSlackDownForArea[ba, tp] ) m.Spinning_Reserve_Up_Provisions.append('HydrogenSpinningReserveUp') m.Spinning_Reserve_Down_Provisions.append('HydrogenSpinningReserveDown') else: # using older formulation, only one type of spinning reserves, indexed by balancing area, timepoint if m.options.hydrogen_reserve_types != ['spinning']: raise ValueError( 'Unable to use reserve types other than "spinning" with simple spinning reserves module.' ) m.Spinning_Reserve_Up_Provisions.append('HydrogenSlackUpForArea') m.Spinning_Reserve_Down_Provisions.append('HydrogenSlackDownForArea')
def define_components(mod): """ STORAGE_GENS is the subset of projects that can provide energy storage. STORAGE_GEN_BLD_YRS is the subset of GEN_BLD_YRS, restricted to storage projects. gen_storage_efficiency[STORAGE_GENS] describes the round trip efficiency of a storage technology. A storage technology that is 75 percent efficient would have a storage_efficiency of .75. If 1 MWh was stored in such a storage project, 750 kWh would be available for extraction later. Internal leakage or energy dissipation of storage technologies is assumed to be neglible, which is consistent with short-duration storage technologies currently on the market which tend to consume stored power within 1 day. If a given storage technology has significant internal discharge when it stores power for extended time perios, then those behaviors will need to be modeled in more detail. gen_store_to_release_ratio[STORAGE_GENS] describes the maximum rate that energy can be stored, expressed as a ratio of discharge power capacity. This is an optional parameter and will default to 1. If a storage project has 1 MW of dischage capacity and a max_store_rate of 1.2, then it can consume up to 1.2 MW of power while charging. gen_storage_energy_overnight_cost[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is the overnight capital cost per MWh of energy capacity for building the given storage technology installed in the given investment period. This is only defined for storage technologies. Note that this describes the energy component and the overnight_cost describes the power component. BuildStorageEnergy[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is a decision of how much energy capacity to build onto a storage project. This is analogous to BuildGen, but for energy rather than power. StorageEnergyInstallCosts[PERIODS] is an expression of the annual costs incurred by the BuildStorageEnergy decision. StorageEnergyCapacity[g, period] is an expression describing the cumulative available energy capacity of BuildStorageEnergy. This is analogous to GenCapacity. STORAGE_GEN_TPS is the subset of GEN_TPS, restricted to storage projects. ChargeStorage[(g, t) in STORAGE_GEN_TPS] is a dispatch decision of how much to charge a storage project in each timepoint. StorageNetCharge[LOAD_ZONE, TIMEPOINT] is an expression describing the aggregate impact of ChargeStorage in each load zone and timepoint. Charge_Storage_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains ChargeStorage to available power capacity (accounting for gen_store_to_release_ratio) StateOfCharge[(g, t) in STORAGE_GEN_TPS] is a variable for tracking state of charge. This value stores the state of charge at the end of each timepoint for each storage project. Track_State_Of_Charge[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on the StateOfCharge in the previous timepoint, ChargeStorage and DispatchGen. State_Of_Charge_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on installed energy capacity. """ mod.STORAGE_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_storage_efficiency = Param(mod.STORAGE_GENS, within=PercentFraction) mod.gen_store_to_release_ratio = Param(mod.STORAGE_GENS, within=PositiveReals, default=1.0) mod.STORAGE_GEN_BLD_YRS = Set( dimen=2, initialize=mod.GEN_BLD_YRS, filter=lambda m, g, bld_yr: g in m.STORAGE_GENS) mod.gen_storage_energy_overnight_cost = Param(mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_storage_energy_overnight_cost') mod.BuildStorageEnergy = Var(mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) # Summarize capital costs of energy storage for the objective function. mod.StorageEnergyInstallCosts = Expression( mod.PERIODS, rule=lambda m, p: sum(m.BuildStorageEnergy[ g, bld_yr] * m.gen_storage_energy_overnight_cost[g, bld_yr] * crf( m.interest_rate, m.gen_max_age[g]) for (g, bld_yr) in m.STORAGE_GEN_BLD_YRS)) mod.Cost_Components_Per_Period.append('StorageEnergyInstallCosts') mod.StorageEnergyCapacity = Expression( mod.STORAGE_GENS, mod.PERIODS, rule=lambda m, g, period: sum(m.BuildStorageEnergy[ g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, period])) mod.STORAGE_GEN_TPS = Set(dimen=2, initialize=lambda m: ((g, tp) for g in m.STORAGE_GENS for tp in m.TPS_FOR_GEN[g])) mod.ChargeStorage = Var(mod.STORAGE_GEN_TPS, within=NonNegativeReals) # Summarize storage charging for the energy balance equations def StorageNetCharge_rule(m, z, t): # Construct and cache a set for summation as needed if not hasattr(m, 'Storage_Charge_Summation_dict'): m.Storage_Charge_Summation_dict = collections.defaultdict(set) for g, t2 in m.STORAGE_GEN_TPS: z2 = m.gen_load_zone[g] m.Storage_Charge_Summation_dict[z2, t2].add(g) # Use pop to free memory relevant_projects = m.Storage_Charge_Summation_dict.pop((z, t)) return sum(m.ChargeStorage[g, t] for g in relevant_projects) mod.StorageNetCharge = Expression(mod.LOAD_ZONES, mod.TIMEPOINTS, rule=StorageNetCharge_rule) # Register net charging with zonal energy balance. Discharging is already # covered by DispatchGen. mod.Zone_Power_Withdrawals.append('StorageNetCharge') def Charge_Storage_Upper_Limit_rule(m, g, t): return m.ChargeStorage[g,t] <= \ m.DispatchUpperLimit[g, t] * m.gen_store_to_release_ratio[g] mod.Charge_Storage_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=Charge_Storage_Upper_Limit_rule) mod.StateOfCharge = Var(mod.STORAGE_GEN_TPS, within=NonNegativeReals) def Track_State_Of_Charge_rule(m, g, t): return m.StateOfCharge[g, t] == \ m.StateOfCharge[g, m.tp_previous[t]] + \ (m.ChargeStorage[g, t] * m.gen_storage_efficiency[g] - m.DispatchGen[g, t]) * m.tp_duration_hrs[t] mod.Track_State_Of_Charge = Constraint(mod.STORAGE_GEN_TPS, rule=Track_State_Of_Charge_rule) def State_Of_Charge_Upper_Limit_rule(m, g, t): return m.StateOfCharge[g, t] <= \ m.StorageEnergyCapacity[g, m.tp_period[t]] mod.State_Of_Charge_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=State_Of_Charge_Upper_Limit_rule)
def define_components(mod): """ STORAGE_GENS is the subset of projects that can provide energy storage. STORAGE_GEN_BLD_YRS is the subset of GEN_BLD_YRS, restricted to storage projects. gen_storage_efficiency[STORAGE_GENS] describes the round trip efficiency of a storage technology. A storage technology that is 75 percent efficient would have a storage_efficiency of .75. If 1 MWh was stored in such a storage project, 750 kWh would be available for extraction later. Internal leakage or energy dissipation of storage technologies is assumed to be neglible, which is consistent with short-duration storage technologies currently on the market which tend to consume stored power within 1 day. If a given storage technology has significant internal discharge when it stores power for extended time perios, then those behaviors will need to be modeled in more detail. gen_store_to_release_ratio[STORAGE_GENS] describes the maximum rate that energy can be stored, expressed as a ratio of discharge power capacity. This is an optional parameter and will default to 1. If a storage project has 1 MW of dischage capacity and a max_store_rate of 1.2, then it can consume up to 1.2 MW of power while charging. gen_storage_energy_overnight_cost[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is the overnight capital cost per MWh of energy capacity for building the given storage technology installed in the given investment period. This is only defined for storage technologies. Note that this describes the energy component and the overnight_cost describes the power component. BuildStorageEnergy[(g, bld_yr) in STORAGE_GEN_BLD_YRS] is a decision of how much energy capacity to build onto a storage project. This is analogous to BuildGen, but for energy rather than power. StorageEnergyInstallCosts[PERIODS] is an expression of the annual costs incurred by the BuildStorageEnergy decision. StorageEnergyCapacity[g, period] is an expression describing the cumulative available energy capacity of BuildStorageEnergy. This is analogous to GenCapacity. STORAGE_GEN_TPS is the subset of GEN_TPS, restricted to storage projects. ChargeStorage[(g, t) in STORAGE_GEN_TPS] is a dispatch decision of how much to charge a storage project in each timepoint. StorageNetCharge[LOAD_ZONE, TIMEPOINT] is an expression describing the aggregate impact of ChargeStorage in each load zone and timepoint. Charge_Storage_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains ChargeStorage to available power capacity (accounting for gen_store_to_release_ratio) StateOfCharge[(g, t) in STORAGE_GEN_TPS] is a variable for tracking state of charge. This value stores the state of charge at the end of each timepoint for each storage project. Track_State_Of_Charge[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on the StateOfCharge in the previous timepoint, ChargeStorage and DispatchGen. State_Of_Charge_Upper_Limit[(g, t) in STORAGE_GEN_TPS] constrains StateOfCharge based on installed energy capacity. """ mod.STORAGE_GENS = Set(within=mod.GENERATION_PROJECTS) mod.gen_storage_efficiency = Param( mod.STORAGE_GENS, within=PercentFraction) mod.gen_store_to_release_ratio = Param( mod.STORAGE_GENS, within=PositiveReals, default=1.0) mod.STORAGE_GEN_BLD_YRS = Set( dimen=2, initialize=mod.GEN_BLD_YRS, filter=lambda m, g, bld_yr: g in m.STORAGE_GENS) mod.gen_storage_energy_overnight_cost = Param( mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) mod.min_data_check('gen_storage_energy_overnight_cost') mod.BuildStorageEnergy = Var( mod.STORAGE_GEN_BLD_YRS, within=NonNegativeReals) # Summarize capital costs of energy storage for the objective function. mod.StorageEnergyInstallCosts = Expression( mod.PERIODS, rule=lambda m, p: sum(m.BuildStorageEnergy[g, bld_yr] * m.gen_storage_energy_overnight_cost[g, bld_yr] * crf(m.interest_rate, m.gen_max_age[g]) for (g, bld_yr) in m.STORAGE_GEN_BLD_YRS)) mod.Cost_Components_Per_Period.append( 'StorageEnergyInstallCosts') mod.StorageEnergyCapacity = Expression( mod.STORAGE_GENS, mod.PERIODS, rule=lambda m, g, period: sum( m.BuildStorageEnergy[g, bld_yr] for bld_yr in m.BLD_YRS_FOR_GEN_PERIOD[g, period])) mod.STORAGE_GEN_TPS = Set( dimen=2, initialize=lambda m: ( (g, tp) for g in m.STORAGE_GENS for tp in m.TPS_FOR_GEN[g])) mod.ChargeStorage = Var( mod.STORAGE_GEN_TPS, within=NonNegativeReals) # Summarize storage charging for the energy balance equations def StorageNetCharge_rule(m, z, t): # Construct and cache a set for summation as needed if not hasattr(m, 'Storage_Charge_Summation_dict'): m.Storage_Charge_Summation_dict = collections.defaultdict(set) for g, t2 in m.STORAGE_GEN_TPS: z2 = m.gen_load_zone[g] m.Storage_Charge_Summation_dict[z2, t2].add(g) # Use pop to free memory relevant_projects = m.Storage_Charge_Summation_dict.pop((z, t)) return sum(m.ChargeStorage[g, t] for g in relevant_projects) mod.StorageNetCharge = Expression( mod.LOAD_ZONES, mod.TIMEPOINTS, rule=StorageNetCharge_rule) # Register net charging with zonal energy balance. Discharging is already # covered by DispatchGen. mod.Zone_Power_Withdrawals.append('StorageNetCharge') def Charge_Storage_Upper_Limit_rule(m, g, t): return m.ChargeStorage[g,t] <= \ m.DispatchUpperLimit[g, t] * m.gen_store_to_release_ratio[g] mod.Charge_Storage_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=Charge_Storage_Upper_Limit_rule) mod.StateOfCharge = Var( mod.STORAGE_GEN_TPS, within=NonNegativeReals) def Track_State_Of_Charge_rule(m, g, t): return m.StateOfCharge[g, t] == \ m.StateOfCharge[g, m.tp_previous[t]] + \ (m.ChargeStorage[g, t] * m.gen_storage_efficiency[g] - m.DispatchGen[g, t]) * m.tp_duration_hrs[t] mod.Track_State_Of_Charge = Constraint( mod.STORAGE_GEN_TPS, rule=Track_State_Of_Charge_rule) def State_Of_Charge_Upper_Limit_rule(m, g, t): return m.StateOfCharge[g, t] <= \ m.StorageEnergyCapacity[g, m.tp_period[t]] mod.State_Of_Charge_Upper_Limit = Constraint( mod.STORAGE_GEN_TPS, rule=State_Of_Charge_Upper_Limit_rule)