# [LICENSE]
# Copyright (c) 2018 Alliance for Sustainable Energy, LLC. All rights reserved.
#
# NOTICE: This software was developed at least in part by Alliance for Sustainable Energy, LLC ("Alliance") under Contract No. DE-AC36-08GO28308 with the U.S. Department of Energy and the U.S. Government retains for itself and others acting on its behalf a nonexclusive, paid-up, irrevocable worldwide license in the software to reproduce, prepare derivative works, distribute copies to the public, perform publicly and display publicly, and to permit others to do so.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, the above government rights notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, the above government rights notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Redistribution of this software, without modification, must refer to the software by the same designation. Redistribution of a modified version of this software (i) may not refer to the modified version by the same designation, or by any confusingly similar designation, and (ii) must refer to the underlying software originally provided by Alliance as "sssmatch". Except to comply with the foregoing, the term "sssmatch", or any confusingly similar designation may not be used to refer to any modified version of this software or any modified version of the underlying software originally provided by Alliance without the prior written consent of Alliance.
#
# 4. The name of the copyright holder, contributors, the United States Government, the United States Department of Energy, or any of their employees may not be used to endorse or promote products derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER, CONTRIBUTORS, UNITED STATES GOVERNMENT OR UNITED STATES DEPARTMENT OF ENERGY, NOR ANY OF THEIR EMPLOYEES, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# [/LICENSE]
import copy
from enum import Enum, auto
import logging
import os
from shutil import copyfile
from subprocess import call
import numpy as np
import pandas as pds
from sssmatch import SSSMatchError, models_dir
logger = logging.getLogger(__name__)
[docs]class AML(Enum):
"""
Supported Algebraic Modeling Languages
"""
GAMS = auto()
[docs]class Request(object):
RESOURCE_INDEPENDENT = ['Biopower','Coal','NG-CC','NG-CT','Nuclear','Oil-Gas-Steam','Storage']
R2PD_TECHS_MAP = {'Land-based Wind': ['wind'],
'Rooftop PV': ['solar','rooftop'],
'Utility PV': ['solar','one-axis-tracking']}
DEFAULT_GENTYPE_DISTANCE_FILE = os.path.join(models_dir,'default_gendists.csv')
def __init__(self,nodes,generators,dataset,desired_mix,exclusions=[]):
self.nodes = nodes
self.generators = generators
self.dataset = dataset
self.original_desired_mix = desired_mix
self.exclusions = exclusions if exclusions is not None else []
[docs] @classmethod
def nodes_columns(cls,re_types):
return ['node_id','latitude','longitude','peak load (MW)','annual load (GWh)'] + list(re_types)
[docs] @classmethod
def generators_columns(cls):
return ['node_id','generator type','capacity (MW)']
[docs] @classmethod
def generators_swapped_columns(cls):
return ['node_id','from generator type','to generator type','capacity (MW)']
@property
def gentypes(self):
return [gentype for gentype in self.dataset.gentypes if gentype not in self.exclusions]
@property
def current_mix(self):
result = pds.pivot_table(self.generators,
values='capacity (MW)',
index='generator type',
aggfunc=np.sum)
result.columns = ['Current Capacity (MW)']
return result
[docs] def drop_default_gendists(self,filename=None):
"""
Prepopulates a gendists csv and saves it to filename, see fulfill.
"""
data = []
for g in self.gentypes:
for gg in self.gentypes:
if gg == g:
continue
if gg not in self.RESOURCE_INDEPENDENT or \
(g in self.RESOURCE_INDEPENDENT and gg in self.RESOURCE_INDEPENDENT):
data.append([g,gg,0.0])
else:
data.append([g,gg,1.0])
if filename is None:
filename = self.DEFAULT_GENTYPE_DISTANCE_FILE
pds.DataFrame(data,columns=['g','gg','Value']).to_csv(filename,index=False)
[docs] def preprocess(self):
self.desired_mix = copy.deepcopy(self.original_desired_mix)
del self.desired_mix['Capacity Fraction']; del self.desired_mix['Generation Fraction']
# filter desired_mix
self.desired_mix = self.desired_mix[self.desired_mix.index.isin(self.gentypes)]
# scale desired_mix
self.system_load = self.nodes['annual load (GWh)'].sum() / 1000.0
self.genmix_generation = self.annual_useable_generation(
self.original_desired_mix,
self.gentypes)
self.scale_factor = self.system_load / self.genmix_generation
logger.info("System load is {:.3f} TWh. ".format(self.system_load) +
"The generation mix is based on {:.3f} TWh of useable generation. ".format(self.genmix_generation) +
"Thus the generation mix capacities will be scaled by a factor of {}.".format(self.scale_factor))
self.desired_mix = self.desired_mix * self.scale_factor
# determine if request is at all feasible
desired_mw = self.desired_mix['Capacity (GW)'] * 1000.0
desired_mw.name = 'Desired Capacity (MW)'
self.summary = self.current_mix.merge(pds.DataFrame(desired_mw),how='outer',left_index=True,right_index=True)
self.summary.fillna(0.0,inplace=True)
logger.info("Request summary:\n{}".format(self.summary))
assert self._resource_independent_test()
assert self._resource_dependent_test()
def _resource_independent_test(self):
totals = self.summary[self.summary.index.isin(self.RESOURCE_INDEPENDENT)].sum()
if totals['Desired Capacity (MW)'] > totals['Current Capacity (MW)']:
msg = "Unable to create a new generation mix that has more " + \
"resource-independent capacity than the current system." + \
"Resource-independent generation types are: {}.".format(self.RESOURCE_INDEPENDENT) + \
"Current and desired capacity of this sort is:\n{}".format(totals)
raise SSSMatchError(msg)
logger.debug("Resource-independent generation types are: {}.".format(self.RESOURCE_INDEPENDENT) + \
"Current and desired capacity of this sort is:\n{}".format(totals))
return True
def _resource_dependent_test(self):
for gentype in self.summary.index:
if gentype in self.RESOURCE_INDEPENDENT:
continue
desired = self.summary.loc[gentype,'Desired Capacity (MW)']
current = self.summary.loc[gentype,'Current Capacity (MW)']
if desired > current:
if gentype not in self.nodes:
msg = "Unable to create a new generation mix with {} MW {} capacity,".format(desired,gentype) + \
"because there is only {} MW in current capacity, ".format(current) + \
"and no maximum quantity is specified for each node."
raise SSSMatchError(msg)
maximum = self.nodes[gentype].sum()
if desired > maximum:
msg = "Unable to create a new generation mix with {} MW {} capacity, ".format(desired,gentype) + \
"because the specified maximum supply only {} MW.".format(maximum)
raise SSSMatchError(msg)
return True
[docs] @classmethod
def annual_useable_generation(cls,genmix,gentypes):
"""
Returns the useable generation in TWh for genmix.
"""
result = genmix[genmix.index.isin(gentypes)]['Generation (TWh)'].sum()
if 'Curtailment' in genmix.index:
result += genmix.loc['Curtailment','Generation (TWh)']
return result
[docs] def fulfill(self,outdir,gendists=None,precision=0,aml=AML.GAMS):
"""
Arguments:
- outdir (str) - directory in which to run matching model and place
results
- gendists (str) - path to csv of (gentype_from, gentype_to,
distance)
- precision (int) - number of digits after the decimal point to
match desired mix to, in MW
- aml (AML) - value of the AML enum that corresponds to the
algebraic modeling language you would like to use
"""
if not hasattr(self,'summary'):
self.preprocess()
model = None
if aml == AML.GAMS:
model = GamsModel(self,outdir)
assert model is not None, "Choose from one of the available algebraic modeling languages: {}".format(",".join([val.name for val in AML]))
model.setup(gendists=gendists,precision=precision)
model.run()
ret = model.collect_results()
if not ret:
raise SSSMatchError('Running the match model {} failed. Examine outputs in {}.'.format(model.MODEL_FILE,outdir))
self.save_results(outdir)
[docs] def register_results(self,capacity,capacity_added,capacity_kept,
capacity_swapped,capacity_removed,distance):
"""
All of the arguments except for distance are dataframes in the same
format as self.generators. Distance is the scalar objective function
value.
"""
self.capacity = capacity
self.capacity_added = capacity_added
self.capacity_kept = capacity_kept
self.capacity_swapped = capacity_swapped
self.capacity_removed = capacity_removed
self.distance = distance
self.compile_result_summary()
[docs] def compile_result_summary(self):
self.result_summary = copy.deepcopy(self.summary)
kept_mw = pds.pivot_table(self.capacity_kept,
values='capacity (MW)',
index='generator type',
aggfunc=np.sum)
kept_mw.columns = ['kept (MW)']
self.result_summary = self.result_summary.merge(pds.DataFrame(kept_mw),how='outer',left_index=True,right_index=True)
swapped_out = pds.pivot_table(self.capacity_swapped,
values='capacity (MW)',
index='from generator type',
aggfunc=np.sum)
swapped_out.columns = ['swapped out (MW)']
self.result_summary = self.result_summary.merge(pds.DataFrame(swapped_out),how='outer',left_index=True,right_index=True)
swapped_in = pds.pivot_table(self.capacity_swapped,
values='capacity (MW)',
index='to generator type',
aggfunc=np.sum)
swapped_in.columns = ['swapped in (MW)']
self.result_summary = self.result_summary.merge(pds.DataFrame(swapped_in),how='outer',left_index=True,right_index=True)
added = pds.pivot_table(self.capacity_added,
values='capacity (MW)',
index='generator type',
aggfunc=np.sum)
added.columns = ['added (MW)']
self.result_summary = self.result_summary.merge(pds.DataFrame(added),how='outer',left_index=True,right_index=True)
removed = pds.pivot_table(self.capacity_removed,
values='capacity (MW)',
index='generator type',
aggfunc=np.sum)
removed.columns = ['removed (MW)']
self.result_summary = self.result_summary.merge(pds.DataFrame(removed),how='outer',left_index=True,right_index=True)
final = pds.pivot_table(self.capacity,
values='capacity (MW)',
index='generator type',
aggfunc=np.sum)
final.columns = ['final (MW)']
self.result_summary = self.result_summary.merge(pds.DataFrame(final),how='outer',left_index=True,right_index=True)
self.result_summary.fillna(0.0,inplace=True)
total = self.result_summary.sum()
total.name = 'TOTAL'
self.result_summary = pds.concat([self.result_summary,pds.DataFrame(total).T])
return
[docs] def save_results(self,outdir):
assert hasattr(self,'distance'), 'This method can only be run after a successful call to self.fulfill.'
self.capacity.to_csv(os.path.join(outdir,'new_generators.csv'),index=False)
details_dir = os.path.join(outdir,'match_details')
if not os.path.exists(details_dir):
os.mkdir(details_dir)
self.capacity.to_csv(os.path.join(details_dir,'capacity.csv'),index=False)
self.capacity_added.to_csv(os.path.join(details_dir,'capacity_added.csv'),index=False)
self.capacity_kept.to_csv(os.path.join(details_dir,'capacity_kept.csv'),index=False)
self.capacity_swapped.to_csv(os.path.join(details_dir,'capacity_swapped.csv'),index=False)
self.capacity_removed.to_csv(os.path.join(details_dir,'capacity_removed.csv'),index=False)
for gentype, r2pd_call in self.R2PD_TECHS_MAP.items():
r2pd_dir = os.path.join(outdir,"_".join(r2pd_call))
df = self.capacity[self.capacity['generator type'] == gentype][['node_id','capacity (MW)']]
if df.empty:
continue
if not os.path.exists(r2pd_dir):
os.mkdir(r2pd_dir)
df.to_csv(os.path.join(r2pd_dir,'generators.csv'),index=False)
self.nodes.iloc[:,:3].to_csv(os.path.join(r2pd_dir,'nodes.csv'),index=False)
return
[docs] def print_report(self):
print(self.result_summary)
[docs]class Model(object):
"""
Base class for optimization models that determine the new, by node and
generation type, generation mix for a given power system.
"""
MODEL_FILE = None
def __init__(self,request,outdir):
self.request = request
self.outdir = outdir
[docs] def setup(self,gendists=None,precision=0):
if not os.path.exists(self.outdir):
os.mkdir(self.outdir)
copyfile(os.path.join(models_dir,self.MODEL_FILE),os.path.join(self.outdir,self.MODEL_FILE))
gendists_filename = gendists if gendists is not None else self.request.DEFAULT_GENTYPE_DISTANCE_FILE
gendists_df = pds.read_csv(gendists_filename)
gendists_df.columns = ['g','gg','Value']
gendists_df = gendists_df[gendists_df.g.isin(self.request.gentypes) & gendists_df.gg.isin(self.request.gentypes)]
desired_capacity_df = pds.DataFrame(self.request.summary['Desired Capacity (MW)'].apply(lambda x: round(x,precision)))
desired_capacity_df = desired_capacity_df.reset_index()
desired_capacity_df.columns = ['g','Value']
return gendists_df, desired_capacity_df
[docs] def collect_results(self): pass
[docs]class GamsModel(Model):
"""
Realization of Model using the GAMS AML. Depends on gdx-pandas, which is
a Python package available on github.com.
"""
MODEL_FILE = 'match_generators.gms'
def __init__(self,request,outdir):
super().__init__(request,outdir)
[docs] def setup(self,gendists=None,precision=0):
gendists_df, desired_capacity_df = super().setup(gendists=gendists,precision=precision)
from gdxpds.gdx import GdxFile, GdxSymbol, GamsDataType
with GdxFile() as ingdx:
# Sets
ingdx.append(GdxSymbol('n',GamsDataType.Set,dims=['n']))
df = pds.DataFrame(self.request.nodes['node_id'])
df['Value'] = True
ingdx[-1].dataframe = df
ingdx.append(GdxSymbol('g',GamsDataType.Set,dims=['g']))
df = pds.DataFrame([[g, True] for g in self.request.gentypes],
columns=['g','Value'])
ingdx[-1].dataframe = df
ingdx.append(GdxSymbol('g_indep',GamsDataType.Set,dims=['g']))
df = pds.DataFrame([[g, True] for g in self.request.gentypes if g in self.request.RESOURCE_INDEPENDENT],
columns=['g','Value'])
ingdx[-1].dataframe = df
ingdx.append(GdxSymbol('g_dep',GamsDataType.Set,dims=['g']))
df = pds.DataFrame([[g, True] for g in self.request.gentypes if g not in self.request.RESOURCE_INDEPENDENT],
columns=['g','Value'])
ingdx[-1].dataframe = df
# Parameters
ingdx.append(GdxSymbol('desired_capacity',GamsDataType.Parameter,dims=['g']))
ingdx[-1].dataframe = desired_capacity_df
ingdx.append(GdxSymbol('current_capacity',GamsDataType.Parameter,dims=['n','g']))
# pivot with sum on capacity in case there are multiple units of type g at node n
df = pds.pivot_table(self.request.generators,
values='capacity (MW)',
index=['node_id','generator type'],
aggfunc=np.sum)
df = df.reset_index()
df.columns = ['n','g','Value']
ingdx[-1].dataframe = df
ingdx.append(GdxSymbol('g_dist',GamsDataType.Parameter,dims=['g','gg']))
ingdx[-1].dataframe = gendists_df
ingdx.append(GdxSymbol('current_indep_capacity',GamsDataType.Parameter,dims=['n']))
df = pds.pivot_table(self.request.generators[self.request.generators['generator type'].isin(self.request.RESOURCE_INDEPENDENT)],
values='capacity (MW)',
index=['node_id'],
aggfunc=np.sum)
df = df.reset_index()
df.columns = ['n','Value']
ingdx[-1].dataframe = df
ingdx.append(GdxSymbol('maximum_capacity',GamsDataType.Parameter,dims=['n','g_dep']))
data = []
for g in self.request.gentypes:
if g in self.request.RESOURCE_INDEPENDENT:
continue
if g in self.request.nodes:
tmp = pds.DataFrame(self.request.nodes['node_id'])
tmp['g_dep'] = g
tmp['Value'] = self.request.nodes[g]
data.append(tmp)
df = pds.concat(data)
df.columns = ['n','g_dep','Value']
ingdx[-1].dataframe = df
ingdx.write(os.path.join(self.outdir,'in.gdx'))
[docs] def run(self):
curdir = os.getcwd()
os.chdir(self.outdir)
call(['gams',self.MODEL_FILE])
os.chdir(curdir)
[docs] def collect_results(self):
result_file = os.path.join(self.outdir,'MatchGenerationMix_p.gdx')
if not os.path.exists(result_file):
return False
from gdxpds.gdx import GdxFile
with GdxFile() as p_gdx:
p_gdx.read(result_file)
variables = [('Capacity',self.request.generators_columns()),
('CapacityAdded',self.request.generators_columns()),
('CapacityKept',self.request.generators_columns()),
('CapacitySwapped',self.request.generators_swapped_columns()),
('CapacityRemoved',self.request.generators_columns()),
('Distance',['Level'])]
capacity_column = self.request.generators_columns()[-1]
args = []
for variable_name, column_names in variables:
p_gdx[variable_name].load()
tmp = p_gdx[variable_name].dataframe.iloc[:,:(len(column_names))]
tmp.columns = column_names
# clear out 0 capacity entries
if capacity_column in tmp.columns:
tmp = tmp[tmp[capacity_column] > 0.0]
args.append(tmp)
args[-1] = args[-1]['Level'].values[0]
self.request.register_results(*args)
return True