├── gridsim ├── test │ ├── __init__.py │ └── grid_sim_linear_program_test.py ├── data │ └── costs │ │ ├── storage_costs.csv │ │ ├── regional_hydro_limits.csv │ │ └── source_costs.csv ├── __init__.py ├── grid_sim_simple_example.py ├── grid_sim_website_example.py └── grid_sim_linear_program.py ├── .gitignore ├── todo ├── MANIFEST.in ├── CHANGES.txt ├── CONTRIBUTING.md ├── LICENSE ├── setup.py └── README.txt /gridsim/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | dist/ 3 | build/ 4 | MANIFEST 5 | *.pyc -------------------------------------------------------------------------------- /todo: -------------------------------------------------------------------------------- 1 | Figure out how to include protobufs or decide to forego them. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include docs *.txt 3 | recursive-include gridsim/data * -------------------------------------------------------------------------------- /gridsim/data/costs/storage_costs.csv: -------------------------------------------------------------------------------- 1 | ,fixed,charge_efficiency,discharge_efficiency,charge_capital,discharge_capital 2 | HYDROGEN,180,0.79,0.45,460000,813000 3 | ELECTROCHEMICAL,187000,0.9,0.9,100000,100000 4 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | v0.1.0, 12/12/2016 -- Initial release. 2 | v0.1.1, 03/01/2017 -- Moved LICENSE.txt to LICENSE per google open-source cross check. 3 | v0.1.3, 03/08/2017 -- Removed protobuf support 4 | v1.0.0, 06/02/2017 -- Added Transmission, GridRecStorage and Tests. 5 | -------------------------------------------------------------------------------- /gridsim/data/costs/regional_hydro_limits.csv: -------------------------------------------------------------------------------- 1 | ,max_energy,max_power 2 | midatlantic,7237000,2755.3 3 | ercot,956000,658.9 4 | carolinas,7306000,3239.5 5 | central,11312000,3369.8 6 | midwest,12317000,3254.5 7 | northwest,126561000,33849.4 8 | nyiso,26015000,4633.8 9 | tva,12984000,3303.5 10 | southeast,12846000,5076.0 11 | california,13808000,9594.8 12 | florida,244000,55.7 13 | neiso,6902000,1798.9 14 | southwest,8899000,3848.7 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to any Google project must be accompanied by a Contributor License 9 | Agreement. This is necessary because you own the copyright to your changes, even 10 | after your contribution becomes part of this project. So this agreement simply 11 | gives us permission to use and redistribute your contributions as part of the 12 | project. Head over to to see your current 13 | agreements on file or to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult [GitHub Help] for more 23 | information on using pull requests. 24 | 25 | [GitHub Help]: https://help.github.com/articles/about-pull-requests/ 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Google 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License.Copyright [yyyy] [name of copyright owner] 14 | 15 | Licensed under the Apache License, Version 2.0 (the "License"); 16 | you may not use this file except in compliance with the License. 17 | You may obtain a copy of the License at 18 | 19 | http://www.apache.org/licenses/LICENSE-2.0 20 | 21 | Unless required by applicable law or agreed to in writing, software 22 | distributed under the License is distributed on an "AS IS" BASIS, 23 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 24 | See the License for the specific language governing permissions and 25 | limitations under the License. 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License.Copyright [yyyy] [name of copyright owner] 14 | 15 | from distutils.core import setup 16 | 17 | setup( 18 | name='gridsim', 19 | version='1.0.0', 20 | author='Orion Pritchard', 21 | author_email='orionp@google.com', 22 | packages=['gridsim', 'gridsim.test'], 23 | package_dir={'gridsim': 'gridsim'}, 24 | package_data={'gridsim': ['data/profiles/profiles_*.csv', 'data/costs/*.csv']}, 25 | scripts=[], 26 | url='http://www.google.com', 27 | license='LICENSE', 28 | description='Files for simulating costs of electrical grid.', 29 | long_description=open('README.txt').read(), 30 | include_package_data=True, 31 | install_requires=[ 32 | 'numpy >= 1.11.1', 33 | 'pandas >= 0.18.1', 34 | 'ortools >= 5.0.3919', 35 | ], 36 | ) 37 | -------------------------------------------------------------------------------- /gridsim/data/costs/source_costs.csv: -------------------------------------------------------------------------------- 1 | ,CO2,fixed,variable 2 | COAL_0,0.85819676404,3388938.68142,23.302 3 | COAL_AMINE_0,0.114140169617,5693261.5974,61.9502007082 4 | COAL_CRYO_0,0.101267218157,4559261.5974,42.8711539972 5 | HYDROPOWER_0,0.0,217839.464837,2.66 6 | NGCC_0,0.329035905198,1239031.76694,17.552 7 | NGCC_1,0.329035905198,1239031.76694,35.16 8 | NGCC_2,0.329035905198,1239031.76694,85.69 9 | NGCC_AMINE_0,0.039813344529,2482557.84509,35.4652226869 10 | NGCC_AMINE_1,0.039813344529,2482557.84509,56.7709026869 11 | NGCC_AMINE_2,0.039813344529,2482557.84509,117.912202687 12 | NGCC_CRYO_0,0.0378391290978,1973557.84509,29.1386042008 13 | NGCC_CRYO_1,0.0378391290978,1973557.84509,49.3878042008 14 | NGCC_CRYO_2,0.0378391290978,1973557.84509,107.497304201 15 | NGCT_0,0.453751127329,770633.274099,32.1005 16 | NGCT_1,0.453751127329,770633.274099,56.3825 17 | NGCT_2,0.453751127329,770633.274099,126.065 18 | NGCT_AMINE_0,0.0585338954255,2014159.35225,57.4621799021 19 | NGCT_AMINE_1,0.0585338954255,2014159.35225,88.7859599021 20 | NGCT_AMINE_2,0.0585338954255,2014159.35225,178.676384902 21 | NGCT_CRYO_0,0.0549038864069,1505159.35225,47.7366325048 22 | NGCT_CRYO_1,0.0549038864069,1505159.35225,77.1178525048 23 | NGCT_CRYO_2,0.0549038864069,1505159.35225,161.433677505 24 | NUCLEAR_0,0.0,2875443.26123,12.0 25 | NUCLEAR_1,0.0,4667257.68165,12.0 26 | NUCLEAR_2,0.0,7333965.3497,10.0 27 | SOLAR_0,0.0,946035.017306,0.0 28 | SOLAR_1,0.0,1356035.01731,0.0 29 | SOLAR_2,0.0,2593035.01731,0.0 30 | WIND_0,0.0,2051532.57887,0.0 31 | WIND_1,0.0,2181532.57887,0.0 32 | WIND_2,0.0,2367532.57887,0.0 33 | -------------------------------------------------------------------------------- /gridsim/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Linear Program Constraints to simulate different energy assumptions. 16 | 17 | This package was originally written to support understanding of energy 18 | solutions within Google Research. It has subsequently been used to 19 | generate scenarios for a website 20 | 21 | http://energystrategies.org 22 | 23 | to help show policy makers, and other interested people, the cost 24 | optimal way to reduce CO2 emissions from the electricity sector. This 25 | package has been released under open-source in order to fully document 26 | how the results of the website were determined and to help further 27 | understanding and analysis of energy costs. See ../LICENSE for more 28 | details of the release. 29 | 30 | The reason for using this model is to avoid the limitations of 31 | Levelized Cost of Electricity (LCOE) which is a popular metric for 32 | computing costs within the energy sector. The idea behind LCOE is to 33 | find an equivalent cost between different energy sources which have 34 | different capital, fixed and operating costs, and which have different 35 | capacity factors (Percentage of the time when the sources can provide 36 | power e.g. solar power during the day.) 37 | 38 | A major limitation of LCOE is that it makes the assumption that 39 | electricity is fungible, that a kilowatt-hour generated during the day 40 | is equivalent to a kilowatt-hour generated at night. This assumption 41 | falls apart in the real world. As of 2016, there are many articles 42 | which say that solar is cheaper than fossil fuels. Yet due to solar's 43 | intermittency, other energy sources must be built to support nighttime 44 | and cloudy day use. This fundamentally changes the cost optimization 45 | in a way which LCOE does not account for. Since the other energy 46 | sources must be built anyway to support nighttime use, then the proper 47 | analysis would be to compare solar capital costs for power during the 48 | day against the marginal cost of running the other energy sources 49 | during the day. LCOE does not consider this. This package does. 50 | 51 | This package does an hour by hour cost optimization of capital, 52 | variable and operating costs of various different energy sources. It 53 | uses historical hour-by-hour data to determine actual overall demand 54 | and actual power available for intermittent power sources such as 55 | solar and wind power. 56 | 57 | This package uses a linear program (LP) to optimize capital and 58 | variable costs subject to the following basic constraint: 59 | 60 | - Total supply power must meet or exceed total demand for all hours. 61 | (Colloquially called 'Keeping the lights on.') 62 | 63 | Additionally optional constaint is: 64 | 65 | - Renewable Portfolio Standard (RPS): Most states in the US have an 66 | RPS in place. It requires that a certain percentage of the 67 | total power grid come from certain sources (such as wind or 68 | solar). This package allows selection of RPS percentage as well 69 | as which sources should be in the RPS. 70 | 71 | Power Sources specify capital, operating costs, maximum power, energy 72 | limits and co2 emitted. Overall the simulation may also specify a 73 | carbon tax and discount rate of money to determine the current value 74 | of future costs while simulating different cost assumptions. 75 | """ 76 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | =========== 2 | Grid Sim 3 | =========== 4 | 5 | Grid Sim provides a Linear Program based solution for different types 6 | of energy sources. It mainly differs from other simulators in that it 7 | uses actual hour-by-hourprofile data for non-dispatchable sources, 8 | such as wind and solar. This allows for more reliable simulation than 9 | simple LCOE (Levelized Cost of Energy) simulations which assume the 10 | fungibility of energy. E.g. An LCOE analysis wouldn't consider that 11 | solar power cannot provide power in the darkest of night. 12 | 13 | This logic provided the basis for the paper "Analyzing energy 14 | technologies and policies using DOSCOE by John C. Platt and J. Orion 15 | Pritchard". 16 | 17 | It is also the basis for data shown on the website 18 | https://goo.gl/BTtZYl 19 | 20 | =========== 21 | Installation 22 | =========== 23 | 24 | Dependencies: 25 | Grid Sim is dependent on the following packages. 26 | - numpy 27 | - pandas 28 | - protobuf 29 | - ortools 30 | 31 | All packages are downloadable via PyPi. When testing installation 32 | on a linux workstation a simple pip-install of the tarball installed 33 | everything except for ortools. Since the recent bundles of ortools 34 | were in egg form, installing ortools via easy-install proved a 35 | suitable workaround for this problem. 36 | 37 | To install, type: 38 | $ cd 39 | $ python setup.py sdist 40 | $ pip install dist/gridsim-1.0.0.tar.gz 41 | 42 | And then if ortools doesn't install properly: 43 | $ easy_install ortools 44 | 45 | To run simple example: 46 | $ cd gridsim 47 | $ python grid_sim_simple_example.py 48 | 49 | To run an example more like those calculated on the website: 50 | $ cd gridsim 51 | $ python grid_sim_website_example.py 52 | 53 | =========== 54 | Source Tree 55 | =========== 56 | 57 | - grid_sim_linear_program.py: Energy Grid objects wrapped around a linear-program 58 | - grid_configuration_pb2.py: compiled protobufs for grid_sim_linear_program.py 59 | - grid_sim_simple_example.py: Executable python script which runs a simple grid simulation. 60 | - grid_sim_website_example.py: Executable python script which runs a 61 | grid simulation using the input data on the energystrategies.org website. 62 | + data: data used to generate scenarios for website simulations 63 | + profiles: 64 | - profiles_.csv: source and demand profiles for a particular 65 | electrical region. Regions are taken as close as possible from 66 | the EIA definition of the regions. See 67 | http://www.eia.gov/beta/realtime_grid/ for a map of the regions. 68 | Solar and Wind data were take from NREL data which was 69 | identified by state. Since some of the EIA regions do not 70 | perfectly overlap states, bifurcated states were split up into 71 | EIA regions. Profile columns are the energy source type. 72 | Profile rows are hour of the year. Values are in Megawatts for 73 | the hour. 74 | 75 | + costs: 76 | - costs.csv: source costs as used by the website. Rows are of the 77 | form _ where matches the 78 | cost sliders in the website. Some additional sources are 79 | included here which ultimately were not shown on the website. 80 | For the website, all Carbon Capture and Sequestration (CCS) was 81 | done using _CRYO_. Columns are: 82 | 83 | CO2: CO2 tonnes emitted per Megawatt of energy. 84 | 85 | fixed: Combination of capital and yearly fixed operating costs per 86 | Megawatt of capacity. 87 | 88 | variable: Costs per Megawatt-hour of generation. Primarily fuel costs. 89 | 90 | - storage_costs.csv: storage costs. Rows are of the form 91 | . For the website, all storage assumed 92 | ELECTROCHEMICAL (i.e. lithium batteries) Colums are: 93 | 94 | fixed: Combination of capital and yearly fixed operating costs per 95 | Megawatt-hour of capacity. 96 | 97 | charge_efficiency: What fraction of energy gets stored during charging. 98 | 99 | discharge_efficiency: What fraction of energy gets put back on the 100 | grid during discharge. 101 | 102 | charge_capital: Combination of capital and yearly fixed 103 | operating costs per Megawatt of charging capacity. 104 | 105 | discharge_capital: Combination of capital and yearly fixed 106 | operating costs per Megawatt of discharging capacity. 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /gridsim/grid_sim_simple_example.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | """Example entry-point for using grid_sim_linear_program to analyize energy. 17 | 18 | Populates and runs the linear program for a simple case of solar power 19 | and natural gas power. 20 | """ 21 | 22 | import os.path as osp 23 | 24 | import distutils.sysconfig as sysconfig 25 | 26 | import grid_sim_linear_program as gslp 27 | 28 | import pandas as pd 29 | 30 | 31 | def get_data_directory(): 32 | """Returns a path to grid_sim data in site-lib packages.""" 33 | 34 | abs_package_path = osp.dirname(gslp.__file__) 35 | return [abs_package_path, 'data'] 36 | 37 | 38 | def simple_lp(profile_dataframe): 39 | """Builds a simple LP with natural gas and solar. 40 | 41 | Args: 42 | profile_dataframe: A pandas dataframe with source and demand names 43 | in the column header. Hour of year in indexed column-0. 44 | 45 | Returns: 46 | A LinearProgramContainer with natural gas and solar sources. 47 | """ 48 | 49 | lp = gslp.LinearProgramContainer(profile_dataframe) 50 | 51 | 52 | # Must specify a demand to put proper load on the grid. GridDemand 53 | # is linked to the corresponding profile: 54 | # lp.profiles[GridDemand.name] 55 | 56 | lp.add_demands(gslp.GridDemand('DEMAND')) 57 | 58 | # Nondispatchable sources are intermittent and have profiles which 59 | # describe their availability. Building more capacity of these 60 | # sources scales the profile but cannot provide power when the 61 | # profile is 0. (e.g. Solar power in the middle of the night, Wind 62 | # power during a lull)). A nondispatchable GridSource is linked to 63 | # its corresponding profile, profile[GridSource.name] 64 | 65 | lp.add_nondispatchable_sources( 66 | gslp.GridSource( 67 | name='SOLAR', # Matches profile column name for nondispatch. 68 | nameplate_unit_cost=946000, # Aggressive solar cost $/MW 69 | variable_unit_cost=0, # No fuel cost. 70 | co2_per_electrical_energy=0, # Clean energy. 71 | is_rps_source=True)) # In Renewable Portfolio Standard 72 | 73 | # Dispatchable sources can provide power at any-time. The LP will 74 | # optimize generation from these sources on an as-needed basis 75 | # hour-by-hour. 76 | 77 | lp.add_dispatchable_sources( 78 | gslp.GridSource( 79 | name='NG', # Dispatchable, so no name restriction. 80 | nameplate_unit_cost=1239031, # Cost for a combined cycle plant. $/MW 81 | variable_unit_cost=17.5, # Cheap fuel costs assumes fracking. $/MWh 82 | co2_per_electrical_energy=0.33, # Tonnes CO2 / MWh 83 | is_rps_source=False)) # Not in Renewable Portfolio Standard. 84 | 85 | return lp 86 | 87 | 88 | def adjust_lp_policy(lp, 89 | carbon_tax=0, 90 | renewable_portfolio_percentage=30, 91 | annual_discount_rate=0.06, 92 | lifetime_in_years=30): 93 | """Configures the LP based upon macro-economic costs and policy. 94 | 95 | Args: 96 | lp: LinearProgramContainer 97 | carbon_tax: Float cost of emitting co2 in $ / Tonne-of-CO2. 98 | Default is no carbon tax. 99 | 100 | renewable_portfolio_percentage: 0. <= Float <= 100. Percentage of 101 | total generation which must come from sources in the Renewable 102 | Portfolio Standard. LP may not converge if this is set to a 103 | high amount without any storage elements in the LP. Default is 104 | 30%. 105 | 106 | annual_discount_rate: interest rate used in discounted cash flow 107 | analysis to determine the present value of future costs. Default 108 | value is 6% (0.06). 109 | 110 | lifetime_in_years: Float Number of years over which fuel costs 111 | are paid off. Default is 30 years. 112 | """ 113 | 114 | hours_per_year = 24 * 365 115 | lp.carbon_tax = carbon_tax 116 | lp.rps_percent = renewable_portfolio_percentage 117 | lp.cost_of_money = gslp.extrapolate_cost( 118 | 1.0, 119 | annual_discount_rate, 120 | lp.number_of_timeslices / hours_per_year, 121 | lifetime_in_years) 122 | 123 | 124 | def display_lp_results(lp): 125 | """Prints out costs, generation and co2 results for the lp. 126 | 127 | Must be run after lp.solve() is called. 128 | 129 | Args: 130 | lp: LinearProgramContainer containing sources and storage. 131 | """ 132 | 133 | system_cost = 0 134 | system_co2 = 0 135 | 136 | # Loop over sources and display results. 137 | sources = lp.sources 138 | for source in sources: 139 | capacity = source.get_nameplate_solution_value() 140 | generated = sum(source.get_solution_values()) 141 | co2 = source.co2_per_electrical_energy * generated 142 | capital_cost = source.nameplate_unit_cost * capacity 143 | fuel_cost = (source.variable_unit_cost * generated + 144 | co2 * lp.carbon_tax) * lp.cost_of_money 145 | total_source_cost = capital_cost + fuel_cost 146 | 147 | print """SOURCE: %s 148 | Capacity: %.2f Megawatts 149 | Generated: %.2f Megawatt-hours 150 | Emitted: %.2f Tonnes of CO2 151 | Capital Cost: $%.2f 152 | Fuel Cost: $%.2f 153 | Total Cost: $%.2f""" % (source.name, 154 | capacity, 155 | generated, 156 | co2, 157 | capital_cost, 158 | fuel_cost, 159 | total_source_cost) 160 | 161 | system_cost += total_source_cost 162 | system_co2 += co2 163 | 164 | # Loop over storage and display results. 165 | for storage in lp.storage: 166 | capacity = storage.get_nameplate_solution_value() 167 | unused_stored = storage.get_solution_values() 168 | charge_capacity = storage.charge_nameplate.solution_value() 169 | discharge_capacity = storage.discharge_nameplate.solution_value() 170 | storage_cost = sum( 171 | [capacity * storage.storage_nameplate_cost, 172 | charge_capacity * storage.charge_nameplate_cost, 173 | discharge_capacity * storage.discharge_nameplate_cost]) 174 | print """STORAGE: %s 175 | Capacity: %.2f Megawatt-hours 176 | Maximum Charge Power: %.2f Megawatts 177 | Maximum Discharge Power: %.2f Megawatts 178 | Total Cost: $%.2f""" % (storage.name, 179 | capacity, 180 | charge_capacity, 181 | discharge_capacity, 182 | storage_cost) 183 | system_cost += storage_cost 184 | system_co2 += co2 185 | 186 | print 'SYSTEM_COST: $%.2f' % system_cost 187 | print 'SYSTEM_CO2: %.2f Tonnes' % system_co2 188 | 189 | 190 | def main(): 191 | 192 | profiles_path = get_data_directory() + ['profiles', 'profiles_california.csv'] 193 | profiles_file = osp.join(*profiles_path) 194 | 195 | profiles = pd.read_csv(profiles_file, index_col=0, parse_dates=True) 196 | lp = simple_lp(profiles) 197 | adjust_lp_policy(lp) 198 | 199 | if not lp.solve(): 200 | raise ValueError("""LP did not converge. 201 | Failure to solve is usually because of high RPS and no storage.""") 202 | 203 | display_lp_results(lp) 204 | 205 | 206 | if __name__ == '__main__': 207 | main() 208 | -------------------------------------------------------------------------------- /gridsim/grid_sim_website_example.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | """Partial Example of using grid_sim_linear_program to match website results. 17 | 18 | First time users are encouraged to look at grid_sim_simple_example.py 19 | first. 20 | 21 | Analyzes energy similarly to how it was done for the backend to the 22 | website http://energystrategies.org. Note that this only calculates 23 | for one out of the thirteen regions classified by the EIA. To match 24 | the results from the site, you must sum the results from all 13 25 | regions as defined in REGION_HUBS.keys() 26 | 27 | This backend uses a linear program to optimize the cheapest way to 28 | generate electricity subject to certain constraints. The constraints 29 | are: 30 | 1. Hourly generation must always exceed demand. AKA. Keep the lights on. 31 | 2. Solution must have some percentage [0, 100] of power from a 32 | 'Renewable Portfolio Standard' (RPS). Different RPS have 33 | different standards so the code allows the user to specify which 34 | sources to put in the RPS. 35 | 36 | Other factors which affect the linear program output include: 37 | 1. Source fixed and variable costs. ($ / MW and $ / MWH respectively) 38 | 2. Source CO2 per Mwh if there is a Carbon Tax (Tonnes / MWh) 39 | 3. Carbon Tax Value ($ / Tonne) 40 | """ 41 | 42 | import math 43 | 44 | import os.path as osp 45 | 46 | import grid_sim_linear_program as gslp 47 | import grid_sim_simple_example as simple 48 | 49 | import pandas as pd 50 | 51 | 52 | # Where to base a HVDC line. 53 | # key:region, value: (latitude, longitude) in decimal degrees 54 | REGION_HUBS = {'southwest': (33.4484, 112.0740), # Phoenix 55 | 'midatlantic': (40.4406, 79.9959), # Pittsburgh 56 | 'tva': (36.1627, 86.7816), # Nashville 57 | 'midwest': (39.0997, 94.5786), # Kansas City 58 | 'nyiso': (40.7128, 74.0059), # New York City 59 | 'ercot': (30.2672, 97.7431), # Austin 60 | 'northwest': (47.6062, 122.3321), # Seattle 61 | 'central': (41.2524, 95.9980), # Omaha 62 | 'carolinas': (35.2271, 80.8431), # Charlotte 63 | 'southeast': (33.5207, 86.8025), # Birmingham 64 | 'florida': (33.5207, 86.8025), # Orlando 65 | 'neiso': (33.5207, 86.8025), # Boston 66 | 'california': (34.0522, 118.2437)} # Los Angeles 67 | 68 | 69 | def circle_route_distance(coordinates1, coordinates2): 70 | """Calculate the spherical distance between two coordinates on earth. 71 | 72 | As described in https://en.wikipedia.org/wiki/Great-circle_distance 73 | 74 | Args: 75 | coordinates1: A tuple containint (latitude, longitude) in decimal 76 | degrees of point 1. 77 | coordinates2: A tuple containint (latitude, longitude) in decimal 78 | degrees of point 2. 79 | 80 | Returns: 81 | Distance on earth in kilometers between the two points. 82 | """ 83 | 84 | lat1, lon1 = coordinates1 85 | lat2, lon2 = coordinates2 86 | 87 | # Convert to radians. 88 | lat1 *= math.pi / 180. 89 | lon1 *= math.pi / 180. 90 | lat2 *= math.pi / 180. 91 | lon2 *= math.pi / 180. 92 | 93 | delta_lat = abs(lat2 - lat1) 94 | delta_lon = abs(lon2 - lon1) 95 | 96 | # Result angle (asin) cannot be computed if point is exactly on 97 | # opposite side of earth, so special case out that point. All other 98 | # values can be correctly calculated with the haversine, even values 99 | # close to 0, pi. 100 | if delta_lat == math.pi and delta_lon == math.pi: 101 | result_angle = math.pi 102 | else: 103 | haversine_lat = math.sin(delta_lat / 2) ** 2 104 | haversine_lon = math.sin(delta_lon / 2) ** 2 105 | result_angle = 2 * math.asin(math.sqrt( 106 | haversine_lat + math.cos(lat1) * math.cos(lat2) * haversine_lon)) 107 | 108 | earth_radius_km = 6371 109 | return earth_radius_km * result_angle 110 | 111 | 112 | def get_transmission_cost_efficiency(coordinates1, coordinates2): 113 | """Return transmission capital cost $/MW and transmission efficiency. 114 | 115 | Args: 116 | coordinates1: A tuple containint (latitude, longitude) in decimal 117 | degrees of point 1. 118 | coordinates2: A tuple containint (latitude, longitude) in decimal 119 | degrees of point 2. 120 | 121 | Returns: 122 | A tuple containing (cost $/MW, efficiency) elements. 123 | """ 124 | 125 | # Assumptions: HVDC is 97% efficient / 1000 km with 126 | # Costs: $334 / km / MWh 127 | 128 | distance_km = circle_route_distance(coordinates1, coordinates2) 129 | capital_cost_per_mw = distance_km * 334 130 | 131 | efficiency = pow(0.97, distance_km / 1000) 132 | 133 | return (capital_cost_per_mw, efficiency) 134 | 135 | 136 | def configure_sources_and_storage(profile_directory, 137 | region, 138 | source_dataframe, 139 | storage_dataframe, 140 | source_dict_index, 141 | storage_names, 142 | rps_names, 143 | hydrolimits=None): 144 | """Generates a LinearProgramContainer similar to the website. 145 | 146 | Args: 147 | profile_directory: String filepath where profile files of the form 148 | profile_.csv exist. Acceptable values of are 149 | REGION_HUBS.keys(). 150 | 151 | region: String name of the region to generate simulation for. 152 | Acceptable values are REGION_HUBS.keys(). 153 | 154 | source_dataframe: A pandas dataframe with source names in indexed 155 | column-0. 156 | 157 | Index names match the source names in the website with a cost 158 | option index. (e.g. COAL_0, SOLAR_2, NUCLEAR_1). Some sources 159 | only have one cost option associated with them. 160 | Source names are as follows. 161 | COAL, Coal fired plants. 162 | HYDROPOWER, Water turbines fed by a dammed reservoir. 163 | NGCC, Natural Gas Combined Cycle. A natural gas fired turbine 164 | whose exhaust heats a steam turbine for additional power. 165 | NGCT, Natural Gas Conventional Turbine. A natural gas turbine. 166 | NGCC_AMINE, NGCC + Carbon Capture and Sequestration via amine 167 | capture. 168 | NGCC_CRYO, NGCC + Carbon Capture and Sequestration from 169 | capturing the CO2 by freezing into dry-ice. 170 | NUCLEAR, Nuclear power. 171 | SOLAR, Solar power through utility scale photovoltaic panels. 172 | WIND, Wind power through modern conventional wind turbines. 173 | 174 | Column headers include 175 | CO2, Amount of CO2 emitted per generated energy. Units are 176 | Tonnes of CO2 Emitted per Megawatt-hour. 177 | fixed, Cost of building and maintaining a plant per 178 | nameplate-capacity of the plant. Units are $ / Megawatt 179 | 180 | variable, Cost of running the plant based upon generation 181 | energy. Units are $ / Megawatt-hour 182 | 183 | storage_dataframe: A pandas dataframe with storage names in indexed 184 | column-0. 185 | 186 | There are only two kinds of storage considered in this dataframe. 187 | HYDROGEN, Storage charges by generating and storing hydrogen. 188 | Storage discharges by buring hydrogen. 189 | ELECTROCHEMICAL, Storage charges and discharges through batteries. 190 | 191 | Index names match the storage names. 192 | 193 | Column headers include: 194 | fixed, 195 | charge_efficiency, 196 | discharge_efficiency, 197 | charge_capital, 198 | discharge_capital, 199 | 200 | source_dict_index: A dict keyed by source name with index values 201 | for cost. Acceptable index values are either [0] for sources 202 | which only had one cost assumed in the webiste or [0,1,2] for 203 | sources which had 3 choices for cost assumption. 204 | 205 | storage_names: A list of storge names of storage type to add to 206 | the LP. Must match names in the storage_dataframe index. 207 | 208 | rps_names: A list of source names which should be considered in 209 | the Renewable Portfolio Standard. 210 | 211 | hydrolimits: A dict with 'max_power' and 'max_energy' keys. If 212 | specified, hydropower will be limited to this power and energy. 213 | 214 | Returns: 215 | A Configured LinearProgramContainer suitable for simulating. 216 | """ 217 | profiles_file = osp.join(profile_directory, 'profiles_usa.csv') 218 | 219 | profiles_dataframe = pd.read_csv(profiles_file, index_col=0, parse_dates=True) 220 | 221 | lp = gslp.LinearProgramContainer(profiles_dataframe) 222 | 223 | # Specify grid load or demand which has a profile in 224 | # profile_dataframe._DEMAND 225 | lp.add_demands(gslp.GridDemand('%s_DEMAND' % region.upper())) 226 | 227 | # Configure dispatchable and non-dispatchable sources. 228 | for source_name, source_index in source_dict_index.iteritems(): 229 | dataframe_row = source_dataframe.loc['%s_%d' % (source_name, source_index)] 230 | is_rps_source = source_name in rps_names 231 | 232 | # Adjust source_name by region to match profiles and to 233 | # differentiate it from sources from other regions. 234 | regional_source_name = '%s_%s' % (region.upper(), source_name) 235 | source = gslp.GridSource(name=regional_source_name, 236 | nameplate_unit_cost=dataframe_row['fixed'], 237 | variable_unit_cost=dataframe_row['variable'], 238 | co2_per_electrical_energy=dataframe_row['CO2'], 239 | is_rps_source=is_rps_source) 240 | 241 | # For energystrategies.org we assumed that the prime hydropower 242 | # sites have already been developed and built. So for hydropower 243 | # sites, we make the capital cost 0. Here we limit the LP to only 244 | # use as much power and energy as existing sites already provide. 245 | # Without this limitation and with capital cost of 0, the LP will 246 | # assume an infinite supply of cheap hydropower and fulfill demand 247 | # with 100% hydropower. 248 | 249 | if hydrolimits is not None: 250 | if source_name == 'HYDROPOWER': 251 | source.max_power = hydrolimits['max_power'] 252 | source.max_energy = hydrolimits['max_energy'] 253 | 254 | # Non-dispatchable sources have profiles associated with them. 255 | if regional_source_name in profiles_dataframe.columns: 256 | lp.add_nondispatchable_sources(source) 257 | else: 258 | lp.add_dispatchable_sources(source) 259 | 260 | # Add Solar and Wind from other regions. Adjust by additional 261 | # transmission costs and efficiency losses for distance traveled if 262 | # Solar and Wind are in the simulation. The website assumes Solar 263 | # and Wind are always present. 264 | 265 | for other_region in REGION_HUBS: 266 | if other_region != region: 267 | transmission_cost, efficiency = get_transmission_cost_efficiency( 268 | REGION_HUBS[region], 269 | REGION_HUBS[other_region]) 270 | 271 | for source in ['SOLAR', 'WIND']: 272 | if source in source_dict_index: 273 | cost_row = source_dataframe.loc['%s_%d' 274 | % (source, 275 | source_dict_index[source])] 276 | lp.add_nondispatchable_sources( 277 | gslp.GridSource( 278 | name='%s_%s' % (other_region.upper(), source), 279 | nameplate_unit_cost=cost_row['fixed'] + transmission_cost, 280 | variable_unit_cost=cost_row['variable'], 281 | co2_per_electrical_energy=cost_row['CO2'], 282 | is_rps_source=True, 283 | power_coefficient=efficiency) 284 | ) 285 | 286 | for storage_name in storage_names: 287 | dataframe_row = storage_dataframe.loc[storage_name] 288 | storage = gslp.GridRecStorage( 289 | name=storage_name, 290 | storage_nameplate_cost=dataframe_row['fixed'], 291 | charge_nameplate_cost=dataframe_row['charge_capital'], 292 | discharge_nameplate_cost=dataframe_row['discharge_capital'], 293 | charge_efficiency=dataframe_row['charge_efficiency'], 294 | discharge_efficiency=dataframe_row['discharge_efficiency']) 295 | 296 | lp.add_storage(storage) 297 | 298 | return lp 299 | 300 | 301 | def main(): 302 | 303 | region = 'california' 304 | 305 | data_dir = simple.get_data_directory() 306 | source_cost_path = data_dir + ['costs', 'source_costs.csv'] 307 | storage_cost_path = data_dir + ['costs', 'storage_costs.csv'] 308 | hydrolimits_path = data_dir + ['costs', 'regional_hydro_limits.csv'] 309 | profile_path = data_dir + ['profiles'] 310 | 311 | source_costs_file = osp.join(*source_cost_path) 312 | storage_costs_file = osp.join(*storage_cost_path) 313 | hydro_limits_file = osp.join(*hydrolimits_path) 314 | 315 | source_costs_dataframe = pd.read_csv(source_costs_file, index_col=0) 316 | storage_costs_dataframe = pd.read_csv(storage_costs_file, index_col=0) 317 | hydrolimits_dataframe = pd.read_csv(hydro_limits_file, index_col=0) 318 | 319 | ng_cost_index = 0 320 | cost_settings = { 321 | 'COAL': 0, 322 | 'HYDROPOWER': 0, 323 | 'NGCC': ng_cost_index, 324 | 'NGCT': ng_cost_index, 325 | 'NGCC_CRYO': ng_cost_index, 326 | 'WIND': 2, 327 | 'SOLAR': 2, 328 | 'NUCLEAR': 2 329 | } 330 | 331 | storage_names = ['ELECTROCHEMICAL'] 332 | rps_names = ['SOLAR', 'WIND'] 333 | 334 | profile_directory = osp.join(*profile_path) 335 | lp = configure_sources_and_storage( 336 | region=region, 337 | profile_directory=profile_directory, 338 | source_dataframe=source_costs_dataframe, 339 | storage_dataframe=storage_costs_dataframe, 340 | source_dict_index=cost_settings, 341 | storage_names=storage_names, 342 | rps_names=rps_names, 343 | hydrolimits=hydrolimits_dataframe.loc[region] 344 | ) 345 | 346 | simple.adjust_lp_policy( 347 | lp, 348 | carbon_tax=50, # $50 per tonne 349 | renewable_portfolio_percentage=20, # 20% generated from rps_names 350 | annual_discount_rate=0.06, # 6% annual discount rate. 351 | lifetime_in_years=30) # 30 year lifetime 352 | 353 | print 'Solving may take a few minutes...' 354 | if not lp.solve(): 355 | raise ValueError("""LP did not converge. 356 | Failure to solve is usually because of high RPS and no storage.""") 357 | 358 | simple.display_lp_results(lp) 359 | 360 | 361 | if __name__ == '__main__': 362 | main() 363 | -------------------------------------------------------------------------------- /gridsim/test/grid_sim_linear_program_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for grid_sim_linear_program.""" 16 | 17 | import math 18 | 19 | import unittest 20 | 21 | import gridsim.grid_sim_linear_program as gslp 22 | 23 | from gridsim.grid_sim_linear_program import DemandNotSatisfiedError 24 | from gridsim.grid_sim_linear_program import GridDemand 25 | from gridsim.grid_sim_linear_program import GridRecStorage 26 | from gridsim.grid_sim_linear_program import GridSource 27 | from gridsim.grid_sim_linear_program import GridStorage 28 | from gridsim.grid_sim_linear_program import LinearProgramContainer 29 | from gridsim.grid_sim_linear_program import RpsExceedsDemandError 30 | from gridsim.grid_sim_linear_program import RpsPercentNotMetError 31 | 32 | import numpy as np 33 | import numpy.testing as npt 34 | import pandas as pd 35 | 36 | 37 | DEMAND = 'DEMAND' 38 | NG = 'NG' 39 | NG2 = 'NG2' 40 | TIME = 'TIME' 41 | SOLAR = 'SOLAR' 42 | WIND = 'WIND' 43 | NUCLEAR = 'NUCLEAR' 44 | STORAGE = 'STORAGE' 45 | 46 | 47 | class CheckValidationTest(unittest.TestCase): 48 | """Check proper validation of bogus / non bogus values.""" 49 | 50 | def setUp(self): 51 | self.dummy_profile = pd.DataFrame({'bogus_source': np.ones(4)}) 52 | 53 | def testUnreasonableProfileRejected(self): 54 | """Verify profile exists.""" 55 | 56 | # Test len(profile) == 0 handled properly. 57 | with self.assertRaises(ValueError): 58 | unused_lp = LinearProgramContainer(pd.DataFrame()) 59 | 60 | # Test profile is None case handled properly. 61 | with self.assertRaises(ValueError): 62 | unused_lp = LinearProgramContainer(None) 63 | 64 | def testDemandMatchesProfile(self): 65 | """Verify lp checks for demand profiles.""" 66 | 67 | with self.assertRaises(KeyError): 68 | lp = LinearProgramContainer(self.dummy_profile) 69 | lp.add_demands( 70 | GridDemand('Some Demand', 0), 71 | GridDemand('Other Demand', 1) 72 | ) 73 | self.assertTrue(lp.solve()) 74 | 75 | def testAddDispatchableSourceWithProfile(self): 76 | """Ensure Dispatchable Source with profile gets error.""" 77 | 78 | with self.assertRaises(KeyError): 79 | lp = LinearProgramContainer(pd.DataFrame({NG: np.ones(4)})) 80 | lp.add_dispatchable_sources(GridSource(NG, 1e6, 1e6)) 81 | 82 | def testAddNonDispatchableSourceWithoutProfile(self): 83 | """Ensure NonDispatchable Source without profile gets error.""" 84 | 85 | with self.assertRaises(KeyError): 86 | lp = LinearProgramContainer(self.dummy_profile) 87 | lp.add_nondispatchable_sources(GridSource(NG, 1e6, 1e6)) 88 | 89 | def testSolutionValuesCalledBeforeSolve(self): 90 | lp = LinearProgramContainer(self.dummy_profile) 91 | ng = GridSource(NG, 1e6, 1e6) 92 | lp.add_dispatchable_sources(ng) 93 | with self.assertRaises(RuntimeError): 94 | ng.get_solution_values() 95 | 96 | with self.assertRaises(RuntimeError): 97 | ng.get_nameplate_solution_value() 98 | 99 | 100 | class FourTimeSliceTest(unittest.TestCase): 101 | 102 | def setUp(self): 103 | self.demand_profile = np.array([3.0, 0.0, 0.0, 3.0]) 104 | 105 | rng = pd.date_range('1/1/2011', periods=len(self.demand_profile), freq='H') 106 | 107 | df = pd.DataFrame.from_dict( 108 | {DEMAND: self.demand_profile, 109 | SOLAR: np.array([2.0, 0.0, 0.0, 1.0]), 110 | WIND: np.array([1.0, 0.0, 0.0, 0.0]), 111 | TIME: rng} 112 | ) 113 | 114 | self.profiles = df.set_index(TIME, 115 | verify_integrity=True) 116 | 117 | self.lp = LinearProgramContainer(self.profiles) 118 | self.lp.add_demands(GridDemand(DEMAND)) 119 | 120 | self.solar = GridSource(SOLAR, 1e6, 1e6) 121 | self.ng = GridSource(NG, 1e6, 1e6) 122 | 123 | 124 | class ProfileVerification(unittest.TestCase): 125 | """Test we properly validate profiles.""" 126 | 127 | def setUp(self): 128 | self.df = pd.DataFrame(np.arange(24.0).reshape(4, 6)) 129 | 130 | def testOk(self): 131 | """Test an okay profile is passed without error.""" 132 | 133 | LinearProgramContainer(self.df) 134 | 135 | def testNan(self): 136 | """Test Nan is caught.""" 137 | 138 | self.df[self.df > 8] = np.nan 139 | with self.assertRaises(ValueError): 140 | LinearProgramContainer(self.df) 141 | 142 | def testNegative(self): 143 | """Test Negative is caught.""" 144 | 145 | self.df -= 4 146 | with self.assertRaises(ValueError): 147 | LinearProgramContainer(self.df) 148 | 149 | 150 | class TestInitialization(FourTimeSliceTest): 151 | """Test initialization routines work as expected.""" 152 | 153 | def testCostConstraintCreation(self): 154 | """Verify cost constraint gets created upon initialization.""" 155 | lp = self.lp 156 | 157 | ng = GridSource(NG, 1e6, 1e6) 158 | lp.add_dispatchable_sources(ng) 159 | self.assertIsNone(lp.minimize_costs_objective) 160 | lp._initialize_solver() 161 | self.assertIsNotNone(lp.minimize_costs_objective) 162 | 163 | def testVariableCreation(self): 164 | """Check GridSource creates nameplate / timeslice variables.""" 165 | lp = self.lp 166 | ng = GridSource(NG, 1e6, 1e6) 167 | self.assertIsNone(ng.timeslice_variables) 168 | self.assertIsNone(ng.nameplate_variable) 169 | 170 | lp.add_dispatchable_sources(ng) 171 | 172 | lp._initialize_solver() 173 | self.assertIsNotNone(ng.nameplate_variable) 174 | self.assertEqual(len(ng.timeslice_variables), lp.number_of_timeslices) 175 | 176 | def testConservePowerConstraint(self): 177 | """Verify ConservePowerConstraint created upon demand initialization.""" 178 | # try a non-zero grid_region_id 179 | lp = self.lp 180 | 181 | demand = GridDemand(DEMAND, 4) 182 | lp.add_demands(demand) 183 | 184 | self.assertEqual(len(lp.conserve_power_constraint), 0) 185 | lp._initialize_solver() 186 | 187 | self.assertEqual(len(lp.conserve_power_constraint), 2) 188 | self.assertTrue(demand.grid_region_id in lp.conserve_power_constraint) 189 | self.assertEqual(len(lp.conserve_power_constraint[demand.grid_region_id]), 190 | lp.number_of_timeslices) 191 | 192 | 193 | class FourTimeSliceLpResults(FourTimeSliceTest): 194 | """Tests over 4 timeslices which return results.""" 195 | 196 | def testDispatchableOnly(self): 197 | """One Dispatchable Source should fill demand.""" 198 | 199 | # Test over a few different profiles 200 | offset_sin_wave = np.sin(np.linspace(0, 2 * math.pi, 4)) + 2 201 | profiles = [self.demand_profile, 202 | np.zeros(4), 203 | np.ones(4), 204 | np.arange(4) % 2, # 0,1,0,1 205 | offset_sin_wave] 206 | 207 | for profile in profiles: 208 | lp = LinearProgramContainer(pd.DataFrame({DEMAND: profile})) 209 | lp.add_demands(GridDemand(DEMAND)) 210 | 211 | ng = self.ng 212 | lp.add_dispatchable_sources(ng) 213 | self.assertTrue(lp.solve()) 214 | npt.assert_almost_equal(ng.get_solution_values(), profile) 215 | 216 | self.assertAlmostEqual(ng.get_nameplate_solution_value(), 217 | max(profile)) 218 | 219 | def testCheaperDispatchableOnly(self): 220 | """Two Dispatchable Sources. Cheaper one should fill demand.""" 221 | 222 | lp = self.lp 223 | ng1 = self.ng 224 | ng2 = GridSource(NG2, 2e6, 2e6) 225 | lp.add_dispatchable_sources(ng1, ng2) 226 | self.assertTrue(lp.solve()) 227 | 228 | npt.assert_almost_equal(ng1.get_solution_values(), self.demand_profile) 229 | npt.assert_almost_equal(ng2.get_solution_values(), np.zeros(4)) 230 | 231 | self.assertAlmostEqual(ng1.get_nameplate_solution_value(), 232 | max(self.demand_profile)) 233 | 234 | self.assertAlmostEqual(ng2.get_nameplate_solution_value(), 0.0) 235 | 236 | def testMaxPowerCheaperDispatchable(self): 237 | """Two Dispatchable Sources. Cheaper one fills up to max_power.""" 238 | 239 | lp = self.lp 240 | max_power = 1 241 | ng1 = GridSource(NG, 1e6, 1e6, max_power=max_power) 242 | ng2 = GridSource(NG2, 2e6, 2e6) 243 | lp.add_dispatchable_sources(ng1, ng2) 244 | self.assertTrue(lp.solve()) 245 | 246 | max_power_profile = np.array([max_power, 0, 0, max_power]) 247 | remaining = self.demand_profile - max_power_profile 248 | npt.assert_almost_equal(ng1.get_solution_values(), max_power_profile) 249 | npt.assert_almost_equal(ng2.get_solution_values(), remaining) 250 | 251 | self.assertAlmostEqual(ng1.get_nameplate_solution_value(), 252 | max(max_power_profile)) 253 | 254 | self.assertAlmostEqual(ng2.get_nameplate_solution_value(), 255 | max(remaining)) 256 | 257 | def testMaxEnergyCheaperDispatchable(self): 258 | """Two Dispatchable Sources. Cheaper one fills up to max_energy.""" 259 | 260 | lp = self.lp 261 | max_energy = 1.0 262 | ng1 = GridSource(NG, 1e6, 1e6, max_energy=max_energy) 263 | ng2 = GridSource(NG2, 2e6, 2e6) 264 | lp.add_dispatchable_sources(ng1, ng2) 265 | self.assertTrue(lp.solve()) 266 | 267 | demand_profiles = self.demand_profile 268 | demand_energy = sum(demand_profiles) 269 | max_energy_profile = (max_energy / demand_energy) * demand_profiles 270 | remaining = demand_profiles - max_energy_profile 271 | 272 | npt.assert_almost_equal(ng1.get_solution_values(), max_energy_profile) 273 | npt.assert_almost_equal(ng2.get_solution_values(), remaining) 274 | 275 | self.assertAlmostEqual(ng1.get_nameplate_solution_value(), 276 | max(max_energy_profile)) 277 | 278 | self.assertAlmostEqual(ng2.get_nameplate_solution_value(), 279 | max(remaining)) 280 | 281 | def testNonDispatchable(self): 282 | """Ensure a non-dispatchable source with proper profile can fill demand.""" 283 | lp = self.lp 284 | 285 | solar = self.solar 286 | lp.add_nondispatchable_sources(solar) 287 | self.assertTrue(lp.solve()) 288 | 289 | self.assertAlmostEqual(solar.get_nameplate_solution_value(), 6.0) 290 | npt.assert_almost_equal(solar.get_solution_values(), 291 | np.array([6.0, 0, 0, 3.0])) 292 | 293 | self.profiles[DEMAND] = 1 294 | self.assertFalse(lp.solve()) 295 | 296 | def testNonDispatchablePowerCoefficient(self): 297 | """Verify that a PowerCoefficient of 0.5 doubles the nameplate.""" 298 | lp = self.lp 299 | 300 | solar = self.solar 301 | solar.power_coefficient = 0.5 302 | lp.add_nondispatchable_sources(solar) 303 | self.assertTrue(lp.solve()) 304 | 305 | self.assertAlmostEqual(solar.get_nameplate_solution_value(), 12.0) 306 | npt.assert_almost_equal(solar.get_solution_values(), 307 | np.array([6.0, 0, 0, 3.0])) 308 | 309 | self.profiles[DEMAND] = 1 310 | self.assertFalse(lp.solve()) 311 | 312 | 313 | class SimpleStorageTest(FourTimeSliceTest): 314 | """Preliminary tests of Storage with power and energy limits.""" 315 | 316 | def testSimpleStorage(self): 317 | """Free Storage should backup Solar.""" 318 | solar = GridSource(SOLAR, 2.0e6, 0) 319 | wind = GridSource(WIND, 5.0e6, 0) 320 | ng = GridSource(NG, 1.0e10, 0) 321 | storage = GridStorage(STORAGE, 0) 322 | 323 | lp = self.lp 324 | lp.add_nondispatchable_sources(solar, wind) 325 | lp.add_dispatchable_sources(ng) 326 | lp.add_storage(storage) 327 | self.assertTrue(lp.solve()) 328 | 329 | npt.assert_almost_equal(solar.get_solution_values(), 330 | np.array([4.0, 0, 0, 2.0])) 331 | 332 | npt.assert_almost_equal(wind.get_solution_values(), 333 | np.zeros(4)) 334 | 335 | npt.assert_almost_equal(ng.get_solution_values(), 336 | np.zeros(4)) 337 | 338 | npt.assert_almost_equal(storage.get_solution_values(), 339 | np.array([0, 1, 1, 1])) 340 | 341 | npt.assert_almost_equal(storage.source.get_solution_values(), 342 | np.array([0, 0, 0, 1])) 343 | 344 | npt.assert_almost_equal(storage.sink.get_solution_values(), 345 | np.array([1, 0, 0, 0])) 346 | 347 | def testSimpleStorageWithStorageLimit(self): 348 | """Free Storage should backup Solar.""" 349 | solar = GridSource(SOLAR, 2.0e6, 0) 350 | wind = GridSource(WIND, 5.0e6, 0) 351 | ng = GridSource(NG, 1.0e10, 0) 352 | storage = GridStorage(STORAGE, 0, max_storage=0.5) 353 | 354 | lp = self.lp 355 | lp.add_nondispatchable_sources(solar, wind) 356 | lp.add_dispatchable_sources(ng) 357 | lp.add_storage(storage) 358 | self.assertTrue(lp.solve()) 359 | 360 | npt.assert_almost_equal(solar.get_solution_values(), 361 | np.array([5.0, 0, 0, 2.5])) 362 | 363 | npt.assert_almost_equal(wind.get_solution_values(), 364 | np.zeros(4)) 365 | 366 | npt.assert_almost_equal(ng.get_solution_values(), 367 | np.zeros(4)) 368 | 369 | npt.assert_almost_equal(storage.get_solution_values(), 370 | np.array([0, 0.5, 0.5, 0.5])) 371 | 372 | npt.assert_almost_equal(storage.source.get_solution_values(), 373 | np.array([0, 0, 0, 0.5])) 374 | 375 | npt.assert_almost_equal(storage.sink.get_solution_values(), 376 | np.array([0.5, 0, 0, 0])) 377 | 378 | def testSimpleStorageWithSourceLimit(self): 379 | """Tests Solar, Wind, Storage. Solar limited so wind makes up the rest.""" 380 | solar = GridSource(SOLAR, 2.0e6, 0, max_energy=3) 381 | wind = GridSource(WIND, 5.0e6, 0) 382 | ng = GridSource(NG, 1.0e10, 0) 383 | storage = GridStorage(STORAGE, 0) 384 | 385 | lp = self.lp 386 | lp.add_nondispatchable_sources(solar, wind) 387 | lp.add_dispatchable_sources(ng) 388 | lp.add_storage(storage) 389 | self.assertTrue(lp.solve()) 390 | 391 | npt.assert_almost_equal(solar.get_solution_values(), 392 | np.array([2.0, 0, 0, 1.0])) 393 | 394 | npt.assert_almost_equal(wind.get_solution_values(), 395 | np.array([3.0, 0, 0, 0.0])) 396 | 397 | npt.assert_almost_equal(ng.get_solution_values(), 398 | np.zeros(4)) 399 | 400 | npt.assert_almost_equal(storage.get_solution_values(), 401 | np.array([0, 2.0, 2.0, 2.0])) 402 | 403 | npt.assert_almost_equal(storage.source.get_solution_values(), 404 | np.array([0, 0, 0, 2.0])) 405 | 406 | npt.assert_almost_equal(storage.sink.get_solution_values(), 407 | np.array([2.0, 0, 0, 0])) 408 | 409 | def testSimpleStorageWithChargeEfficiency(self): 410 | """Free Storage should backup Solar.""" 411 | solar = GridSource(SOLAR, 2.0e6, 0) 412 | wind = GridSource(WIND, 5.0e6, 0) 413 | ng = GridSource(NG, 1.0e10, 0) 414 | storage = GridStorage(STORAGE, 0, charge_efficiency=0.5) 415 | 416 | lp = self.lp 417 | lp.add_nondispatchable_sources(solar, wind) 418 | lp.add_dispatchable_sources(ng) 419 | lp.add_storage(storage) 420 | self.assertTrue(lp.solve()) 421 | 422 | npt.assert_almost_equal(solar.get_solution_values(), 423 | np.array([4.5, 0, 0, 2.25])) 424 | 425 | npt.assert_almost_equal(wind.get_solution_values(), 426 | np.zeros(4)) 427 | 428 | npt.assert_almost_equal(ng.get_solution_values(), 429 | np.zeros(4)) 430 | 431 | npt.assert_almost_equal(storage.get_solution_values(), 432 | np.array([0, 0.75, 0.75, 0.75])) 433 | 434 | npt.assert_almost_equal(storage.source.get_solution_values(), 435 | np.array([0, 0, 0, 0.75])) 436 | 437 | npt.assert_almost_equal(storage.sink.get_solution_values(), 438 | np.array([1.5, 0, 0, 0])) 439 | 440 | def testSimpleStorageWithDischargeEfficiency(self): 441 | """Free Storage should backup Solar.""" 442 | solar = GridSource(SOLAR, 2.0e6, 0) 443 | wind = GridSource(WIND, 5.0e6, 0) 444 | ng = GridSource(NG, 1.0e10, 0) 445 | storage = GridStorage(STORAGE, 0, discharge_efficiency=0.5) 446 | 447 | lp = self.lp 448 | lp.add_nondispatchable_sources(solar, wind) 449 | lp.add_dispatchable_sources(ng) 450 | lp.add_storage(storage) 451 | self.assertTrue(lp.solve()) 452 | 453 | npt.assert_almost_equal(solar.get_solution_values(), 454 | np.array([4.5, 0, 0, 2.25])) 455 | 456 | npt.assert_almost_equal(wind.get_solution_values(), 457 | np.zeros(4)) 458 | 459 | npt.assert_almost_equal(ng.get_solution_values(), 460 | np.zeros(4)) 461 | 462 | npt.assert_almost_equal(storage.get_solution_values(), 463 | np.array([0, 1.5, 1.5, 1.5])) 464 | 465 | npt.assert_almost_equal(storage.source.get_solution_values(), 466 | np.array([0, 0, 0, 1.5])) 467 | 468 | npt.assert_almost_equal(storage.sink.get_solution_values(), 469 | np.array([1.5, 0, 0, 0])) 470 | 471 | def testSimpleStorageWithStorageEfficiency(self): 472 | """Free Storage should backup Solar.""" 473 | solar = GridSource(SOLAR, 2.0e6, 0) 474 | wind = GridSource(WIND, 5.0e6, 0) 475 | ng = GridSource(NG, 1.0e10, 0) 476 | 477 | se = math.pow(0.5, 1.0 / 3) 478 | storage = GridStorage(STORAGE, 0, storage_efficiency=se) 479 | 480 | lp = self.lp 481 | lp.add_nondispatchable_sources(solar, wind) 482 | lp.add_dispatchable_sources(ng) 483 | lp.add_storage(storage) 484 | self.assertTrue(lp.solve()) 485 | 486 | npt.assert_almost_equal(solar.get_solution_values(), 487 | np.array([4.5, 0, 0, 2.25])) 488 | 489 | npt.assert_almost_equal(wind.get_solution_values(), 490 | np.zeros(4)) 491 | 492 | npt.assert_almost_equal(ng.get_solution_values(), 493 | np.zeros(4)) 494 | 495 | npt.assert_almost_equal(storage.get_solution_values(), 496 | np.array([0, 1.5, 1.5 * se, 1.5 * se * se])) 497 | 498 | npt.assert_almost_equal(storage.source.get_solution_values(), 499 | np.array([0, 0, 0, 0.75])) 500 | 501 | npt.assert_almost_equal(storage.sink.get_solution_values(), 502 | np.array([1.5, 0, 0, 0])) 503 | 504 | 505 | class SimpleRecStorageTest(FourTimeSliceTest): 506 | """Preliminary tests of Storage with power and energy limits.""" 507 | 508 | def testSimpleStorage(self): 509 | """Free Storage should backup Solar.""" 510 | solar = GridSource(SOLAR, 2.0e6, 0) 511 | wind = GridSource(WIND, 5.0e6, 0) 512 | ng = GridSource(NG, 1.0e10, 0) 513 | storage = GridRecStorage(STORAGE, 0) 514 | 515 | lp = self.lp 516 | lp.add_nondispatchable_sources(solar, wind) 517 | lp.add_dispatchable_sources(ng) 518 | lp.add_storage(storage) 519 | self.assertTrue(lp.solve()) 520 | 521 | npt.assert_almost_equal(solar.get_solution_values(), 522 | np.array([4.0, 0, 0, 2.0])) 523 | 524 | npt.assert_almost_equal(wind.get_solution_values(), 525 | np.zeros(4)) 526 | 527 | npt.assert_almost_equal(ng.get_solution_values(), 528 | np.zeros(4)) 529 | 530 | npt.assert_almost_equal(storage.get_solution_values(), 531 | np.array([0, 1, 1, 1])) 532 | 533 | npt.assert_almost_equal(storage.get_source_solution_values(), 534 | np.array([-1, 0, 0, 1])) 535 | 536 | def testSimpleStorageWithStorageLimit(self): 537 | """Free Storage should backup Solar.""" 538 | solar = GridSource(SOLAR, 2.0e6, 0) 539 | wind = GridSource(WIND, 5.0e6, 0) 540 | ng = GridSource(NG, 1.0e10, 0) 541 | storage = GridRecStorage(STORAGE, 0, max_storage=0.5) 542 | 543 | lp = self.lp 544 | lp.add_nondispatchable_sources(solar, wind) 545 | lp.add_dispatchable_sources(ng) 546 | lp.add_storage(storage) 547 | self.assertTrue(lp.solve()) 548 | 549 | npt.assert_almost_equal(solar.get_solution_values(), 550 | np.array([5.0, 0, 0, 2.5])) 551 | 552 | npt.assert_almost_equal(wind.get_solution_values(), 553 | np.zeros(4)) 554 | 555 | npt.assert_almost_equal(ng.get_solution_values(), 556 | np.zeros(4)) 557 | 558 | npt.assert_almost_equal(storage.get_solution_values(), 559 | np.array([0, 0.5, 0.5, 0.5])) 560 | 561 | npt.assert_almost_equal(storage.get_source_solution_values(), 562 | np.array([-0.5, 0, 0, 0.5])) 563 | 564 | def testSimpleStorageWithSourceLimit(self): 565 | """Tests Solar, Wind, Storage. Solar limited so wind makes up the rest.""" 566 | solar = GridSource(SOLAR, 2.0e6, 0, max_energy=3) 567 | wind = GridSource(WIND, 5.0e6, 0) 568 | ng = GridSource(NG, 1.0e10, 0) 569 | storage = GridRecStorage(STORAGE, 0) 570 | 571 | lp = self.lp 572 | lp.add_nondispatchable_sources(solar, wind) 573 | lp.add_dispatchable_sources(ng) 574 | lp.add_storage(storage) 575 | self.assertTrue(lp.solve()) 576 | 577 | npt.assert_almost_equal(solar.get_solution_values(), 578 | np.array([2.0, 0, 0, 1.0])) 579 | 580 | npt.assert_almost_equal(wind.get_solution_values(), 581 | np.array([3.0, 0, 0, 0.0])) 582 | 583 | npt.assert_almost_equal(ng.get_solution_values(), 584 | np.zeros(4)) 585 | 586 | npt.assert_almost_equal(storage.get_solution_values(), 587 | np.array([0, 2.0, 2.0, 2.0])) 588 | 589 | npt.assert_almost_equal(storage.get_source_solution_values(), 590 | np.array([-2.0, 0, 0, 2.0])) 591 | 592 | def testSimpleStorageWithChargeEfficiency(self): 593 | """Free Storage should backup Solar.""" 594 | solar = GridSource(SOLAR, 2.0e6, 0) 595 | wind = GridSource(WIND, 5.0e6, 0) 596 | ng = GridSource(NG, 1.0e10, 0) 597 | storage = GridRecStorage(STORAGE, 0, charge_efficiency=0.5) 598 | 599 | lp = self.lp 600 | lp.add_nondispatchable_sources(solar, wind) 601 | lp.add_dispatchable_sources(ng) 602 | lp.add_storage(storage) 603 | self.assertTrue(lp.solve()) 604 | 605 | npt.assert_almost_equal(solar.get_solution_values(), 606 | np.array([4.5, 0, 0, 2.25])) 607 | 608 | npt.assert_almost_equal(wind.get_solution_values(), 609 | np.zeros(4)) 610 | 611 | npt.assert_almost_equal(ng.get_solution_values(), 612 | np.zeros(4)) 613 | 614 | npt.assert_almost_equal(storage.get_solution_values(), 615 | np.array([0, 0.75, 0.75, 0.75])) 616 | 617 | npt.assert_almost_equal(storage.get_source_solution_values(), 618 | np.array([-1.5, 0, 0, 0.75])) 619 | 620 | def testSimpleStorageWithDischargeEfficiency(self): 621 | """Free Storage should backup Solar.""" 622 | solar = GridSource(SOLAR, 2.0e6, 0) 623 | wind = GridSource(WIND, 5.0e6, 0) 624 | ng = GridSource(NG, 1.0e10, 0) 625 | storage = GridRecStorage(STORAGE, 0, discharge_efficiency=0.5) 626 | 627 | lp = self.lp 628 | lp.add_nondispatchable_sources(solar, wind) 629 | lp.add_dispatchable_sources(ng) 630 | lp.add_storage(storage) 631 | self.assertTrue(lp.solve()) 632 | 633 | npt.assert_almost_equal(solar.get_solution_values(), 634 | np.array([4.5, 0, 0, 2.25])) 635 | 636 | npt.assert_almost_equal(wind.get_solution_values(), 637 | np.zeros(4)) 638 | 639 | npt.assert_almost_equal(ng.get_solution_values(), 640 | np.zeros(4)) 641 | 642 | npt.assert_almost_equal(storage.get_solution_values(), 643 | np.array([0, 1.5, 1.5, 1.5])) 644 | 645 | npt.assert_almost_equal(storage.get_source_solution_values(), 646 | np.array([-1.5, 0, 0, 1.5])) 647 | 648 | def testSimpleStorageWithStorageEfficiency(self): 649 | """Free Storage should backup Solar.""" 650 | solar = GridSource(SOLAR, 2.0e6, 0) 651 | wind = GridSource(WIND, 5.0e6, 0) 652 | ng = GridSource(NG, 1.0e10, 0) 653 | 654 | se = math.pow(0.5, 1.0 / 3) 655 | storage = GridRecStorage(STORAGE, 0, storage_efficiency=se) 656 | 657 | lp = self.lp 658 | lp.add_nondispatchable_sources(solar, wind) 659 | lp.add_dispatchable_sources(ng) 660 | lp.add_storage(storage) 661 | self.assertTrue(lp.solve()) 662 | 663 | npt.assert_almost_equal(solar.get_solution_values(), 664 | np.array([4.5, 0, 0, 2.25])) 665 | 666 | npt.assert_almost_equal(wind.get_solution_values(), 667 | np.zeros(4)) 668 | 669 | npt.assert_almost_equal(ng.get_solution_values(), 670 | np.zeros(4)) 671 | 672 | npt.assert_almost_equal(storage.get_solution_values(), 673 | np.array([0, 1.5, 1.5 * se, 1.5 * se * se])) 674 | 675 | npt.assert_almost_equal(storage.get_source_solution_values(), 676 | np.array([-1.5, 0, 0, 0.75])) 677 | 678 | 679 | class TwoTimeSliceTest(unittest.TestCase): 680 | """Tests with only two time slices.""" 681 | 682 | def setUp(self): 683 | solar_profile = np.array([0.0, 1.0]) 684 | wind_profile = np.array([1.0, 0.0]) 685 | demand_profile = np.array([1.0, 1.0]) 686 | self.demand_profile = demand_profile 687 | 688 | rng = pd.date_range('1/1/2011', periods=len(solar_profile), freq='H') 689 | df = pd.DataFrame.from_dict( 690 | { 691 | SOLAR: solar_profile, 692 | WIND: wind_profile, 693 | DEMAND: demand_profile, 694 | TIME: rng 695 | } 696 | ) 697 | 698 | self.profiles = df.set_index(TIME, 699 | verify_integrity=True) 700 | 701 | self.lp = LinearProgramContainer(self.profiles) 702 | self.lp.add_demands(GridDemand(DEMAND)) 703 | 704 | 705 | class CircularStorageTest(TwoTimeSliceTest): 706 | """Tests related to last storage result affecting first storage result.""" 707 | 708 | def testCircularStorageLastHourSource(self): 709 | """Verify that storage from last hour affects first hour.""" 710 | solar = GridSource(SOLAR, 2.0e6, 0) 711 | storage = GridStorage(STORAGE, 0) 712 | 713 | lp = self.lp 714 | lp.add_nondispatchable_sources(solar) 715 | lp.add_storage(storage) 716 | self.assertTrue(lp.solve()) 717 | 718 | npt.assert_almost_equal(solar.get_solution_values(), 719 | np.array([0.0, 2.0])) 720 | 721 | npt.assert_almost_equal(storage.get_solution_values(), 722 | np.array([1.0, 0.0])) 723 | 724 | npt.assert_almost_equal(storage.source.get_solution_values(), 725 | np.array([1.0, 0.0])) 726 | 727 | npt.assert_almost_equal(storage.sink.get_solution_values(), 728 | np.array([0.0, 1.0])) 729 | 730 | def testCircularStorageFirstHourSource(self): 731 | """Verify that storage from first hour affects last hour.""" 732 | wind = GridSource(WIND, 2.0e6, 0) 733 | storage = GridStorage(STORAGE, 0) 734 | 735 | lp = self.lp 736 | lp.add_nondispatchable_sources(wind) 737 | lp.add_storage(storage) 738 | self.assertTrue(lp.solve()) 739 | 740 | npt.assert_almost_equal(wind.get_solution_values(), 741 | np.array([2.0, 0.0])) 742 | 743 | npt.assert_almost_equal(storage.get_solution_values(), 744 | np.array([0.0, 1.0])) 745 | 746 | npt.assert_almost_equal(storage.source.get_solution_values(), 747 | np.array([0.0, 1.0])) 748 | 749 | npt.assert_almost_equal(storage.sink.get_solution_values(), 750 | np.array([1.0, 0.0])) 751 | 752 | def testFreeStorageMeansCheapestSource(self): 753 | """Verify that free storage selects the cheapest energy supply.""" 754 | solar = GridSource(SOLAR, 2.0e6, 0) 755 | wind = GridSource(WIND, 2.2e6, 0) 756 | storage = GridStorage(STORAGE, 0) 757 | 758 | lp = self.lp 759 | lp.add_nondispatchable_sources(solar, wind) 760 | lp.add_storage(storage) 761 | self.assertTrue(lp.solve()) 762 | 763 | npt.assert_almost_equal(wind.get_solution_values(), 764 | np.zeros(2)) 765 | 766 | npt.assert_almost_equal(solar.get_solution_values(), 767 | np.array([0.0, 2.0])) 768 | 769 | npt.assert_almost_equal(storage.get_solution_values(), 770 | np.array([1.0, 0.0])) 771 | 772 | npt.assert_almost_equal(storage.source.get_solution_values(), 773 | np.array([1.0, 0.0])) 774 | 775 | npt.assert_almost_equal(storage.sink.get_solution_values(), 776 | np.array([0.0, 1.0])) 777 | 778 | 779 | class CircularRecStorageTest(TwoTimeSliceTest): 780 | """Tests related to last storage result affecting first storage result.""" 781 | 782 | def testCircularRecStorageLastHourSource(self): 783 | """Verify that storage from last hour affects first hour.""" 784 | solar = GridSource(SOLAR, 2.0e6, 0) 785 | storage = GridRecStorage(STORAGE, 0) 786 | 787 | lp = self.lp 788 | lp.add_nondispatchable_sources(solar) 789 | lp.add_storage(storage) 790 | self.assertTrue(lp.solve()) 791 | 792 | npt.assert_almost_equal(solar.get_solution_values(), 793 | np.array([0.0, 2.0])) 794 | 795 | npt.assert_almost_equal(storage.get_solution_values(), 796 | np.array([1.0, 0.0])) 797 | 798 | npt.assert_almost_equal(storage.get_source_solution_values(), 799 | np.array([1.0, -1.0])) 800 | 801 | def testCircularRecStorageFirstHourSource(self): 802 | """Verify that storage from first hour affects last hour.""" 803 | wind = GridSource(WIND, 2.0e6, 0) 804 | storage = GridRecStorage(STORAGE, 0) 805 | 806 | lp = self.lp 807 | lp.add_nondispatchable_sources(wind) 808 | lp.add_storage(storage) 809 | self.assertTrue(lp.solve()) 810 | 811 | npt.assert_almost_equal(wind.get_solution_values(), 812 | np.array([2.0, 0.0])) 813 | 814 | npt.assert_almost_equal(storage.get_solution_values(), 815 | np.array([0.0, 1.0])) 816 | 817 | npt.assert_almost_equal(storage.get_source_solution_values(), 818 | np.array([-1.0, 1.0])) 819 | 820 | def testFreeRecStorageMeansCheapestSource(self): 821 | """Verify that free storage selects the cheapest energy supply.""" 822 | solar = GridSource(SOLAR, 2.0e6, 0) 823 | wind = GridSource(WIND, 2.2e6, 0) 824 | storage = GridRecStorage(STORAGE, 0) 825 | 826 | lp = self.lp 827 | lp.add_nondispatchable_sources(solar, wind) 828 | lp.add_storage(storage) 829 | self.assertTrue(lp.solve()) 830 | 831 | npt.assert_almost_equal(wind.get_solution_values(), 832 | np.zeros(2)) 833 | 834 | npt.assert_almost_equal(solar.get_solution_values(), 835 | np.array([0.0, 2.0])) 836 | 837 | npt.assert_almost_equal(storage.get_solution_values(), 838 | np.array([1.0, 0.0])) 839 | 840 | npt.assert_almost_equal(storage.get_source_solution_values(), 841 | np.array([1.0, -1.0])) 842 | 843 | 844 | class StorageCostsTest(TwoTimeSliceTest): 845 | """Test Solutions with different storage costs.""" 846 | 847 | def testStorageNameplateCost(self): 848 | """Keep increasing storage costs until ng finally wins out.""" 849 | wind = GridSource(WIND, 1.0e6, 0) 850 | storage = GridStorage(STORAGE, 0) 851 | ng = GridSource(NG, 4.6e6, 0) 852 | lp = self.lp 853 | lp.add_nondispatchable_sources(wind) 854 | lp.add_dispatchable_sources(ng) 855 | lp.add_storage(storage) 856 | 857 | self.assertTrue(lp.solve()) 858 | 859 | npt.assert_almost_equal(wind.get_solution_values(), 860 | np.array([2.0, 0.0])) 861 | 862 | npt.assert_almost_equal(ng.get_solution_values(), 863 | np.zeros(2)) 864 | 865 | # Change costs. Total wind + storage cost is now (2 + 1)E6. 866 | # Still less than ng costs. 867 | storage.charge_nameplate_cost = 1.0e6 868 | self.assertTrue(lp.solve()) 869 | 870 | npt.assert_almost_equal(wind.get_solution_values(), 871 | np.array([2.0, 0.0])) 872 | 873 | npt.assert_almost_equal(ng.get_solution_values(), 874 | np.zeros(2)) 875 | 876 | # Change Costs. Total wind + storage cost is now (2 + 1 + 1)E6. 877 | # Still less than ng costs. 878 | storage.storage_nameplate_cost = 1.0e6 879 | self.assertTrue(lp.solve()) 880 | 881 | npt.assert_almost_equal(wind.get_solution_values(), 882 | np.array([2.0, 0.0])) 883 | 884 | npt.assert_almost_equal(ng.get_solution_values(), 885 | np.zeros(2)) 886 | 887 | # Change costs. Total wind + storage cost is now 888 | # (2 + 1 + 1 + 1)E6. Now more than ng costs. 889 | storage.discharge_nameplate_cost = 1.0e6 890 | self.assertTrue(lp.solve()) 891 | 892 | npt.assert_almost_equal(wind.get_solution_values(), 893 | np.zeros(2)) 894 | 895 | npt.assert_almost_equal(ng.get_solution_values(), 896 | np.array([1.0, 1.0])) 897 | 898 | 899 | class RecStorageCostsTest(TwoTimeSliceTest): 900 | """Test Solutions with different storage costs.""" 901 | 902 | def testStorageNameplateCost(self): 903 | """Keep increasing storage costs until ng finally wins out.""" 904 | wind = GridSource(WIND, 1.0e6, 0) 905 | storage = GridRecStorage(STORAGE, 0) 906 | ng = GridSource(NG, 4.6e6, 0) 907 | lp = self.lp 908 | lp.add_nondispatchable_sources(wind) 909 | lp.add_dispatchable_sources(ng) 910 | lp.add_storage(storage) 911 | 912 | self.assertTrue(lp.solve()) 913 | 914 | npt.assert_almost_equal(wind.get_solution_values(), 915 | np.array([2.0, 0.0])) 916 | 917 | npt.assert_almost_equal(ng.get_solution_values(), 918 | np.zeros(2)) 919 | 920 | # Change costs. Total wind + storage cost is now (2 + 1)E6. 921 | # Still less than ng costs. 922 | storage.charge_nameplate_cost = 1.0e6 923 | self.assertTrue(lp.solve()) 924 | 925 | npt.assert_almost_equal(wind.get_solution_values(), 926 | np.array([2.0, 0.0])) 927 | 928 | npt.assert_almost_equal(ng.get_solution_values(), 929 | np.zeros(2)) 930 | 931 | # Change Costs. Total wind + storage cost is now (2 + 1 + 1)E6. 932 | # Still less than ng costs. 933 | storage.storage_nameplate_cost = 1.0e6 934 | self.assertTrue(lp.solve()) 935 | 936 | npt.assert_almost_equal(wind.get_solution_values(), 937 | np.array([2.0, 0.0])) 938 | 939 | npt.assert_almost_equal(ng.get_solution_values(), 940 | np.zeros(2)) 941 | 942 | # Change costs. Total wind + storage cost is now 943 | # (2 + 1 + 1 + 1)E6. Now more than ng costs. 944 | storage.discharge_nameplate_cost = 1.0e6 945 | self.assertTrue(lp.solve()) 946 | 947 | npt.assert_almost_equal(wind.get_solution_values(), 948 | np.zeros(2)) 949 | 950 | npt.assert_almost_equal(ng.get_solution_values(), 951 | np.array([1.0, 1.0])) 952 | 953 | 954 | class StorageStepTest(unittest.TestCase): 955 | 956 | def setUp(self): 957 | self.demand_profile = np.array( 958 | [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0]) 959 | 960 | impulse_profile = np.zeros(len(self.demand_profile)) 961 | impulse_profile[0] = 1.0 962 | self.impulse = impulse_profile 963 | 964 | rng = pd.date_range('1/1/2011', periods=len(self.demand_profile), freq='H') 965 | 966 | df = pd.DataFrame.from_dict( 967 | {DEMAND: self.demand_profile, 968 | SOLAR: impulse_profile, 969 | TIME: rng} 970 | ) 971 | 972 | self.profiles = df.set_index(TIME, 973 | verify_integrity=True) 974 | 975 | self.lp = LinearProgramContainer(self.profiles) 976 | self.lp.add_demands(GridDemand(DEMAND)) 977 | 978 | self.solar = GridSource(SOLAR, 1e6, 1e6) 979 | 980 | def testRandomSawToothStorage(self): 981 | lp = self.lp 982 | solar = self.solar 983 | lp.add_nondispatchable_sources(solar) 984 | storage = GridStorage(STORAGE, 0) 985 | lp.add_storage(storage) 986 | 987 | for storage_efficiency in np.linspace(0.1, 1.0, 4): 988 | storage.storage_efficiency = storage_efficiency 989 | self.assertTrue(lp.solve()) 990 | 991 | # build up storage profile 992 | demand_profile = self.demand_profile 993 | golden_storage_profile = [] 994 | last_storage_value = 0.0 995 | for d in reversed(demand_profile): 996 | next_storage_value = (last_storage_value + d) / storage_efficiency 997 | golden_storage_profile.append(next_storage_value) 998 | last_storage_value = next_storage_value 999 | 1000 | golden_storage_profile.reverse() 1001 | # First generation comes from solar, so set golden_storage[0] = 0. 1002 | golden_storage_profile[0] = 0 1003 | golden_solar_profile = self.impulse * golden_storage_profile[1] 1004 | 1005 | npt.assert_allclose(solar.get_solution_values(), 1006 | golden_solar_profile) 1007 | 1008 | npt.assert_allclose(storage.get_solution_values(), 1009 | golden_storage_profile, 1010 | atol=1e-7) 1011 | 1012 | 1013 | class RecStorageStepTest(unittest.TestCase): 1014 | 1015 | def setUp(self): 1016 | self.demand_profile = np.array( 1017 | [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0]) 1018 | 1019 | impulse_profile = np.zeros(len(self.demand_profile)) 1020 | impulse_profile[0] = 1.0 1021 | self.impulse = impulse_profile 1022 | 1023 | rng = pd.date_range('1/1/2011', periods=len(self.demand_profile), freq='H') 1024 | 1025 | df = pd.DataFrame.from_dict( 1026 | {DEMAND: self.demand_profile, 1027 | SOLAR: impulse_profile, 1028 | TIME: rng} 1029 | ) 1030 | 1031 | self.profiles = df.set_index(TIME, 1032 | verify_integrity=True) 1033 | 1034 | self.lp = LinearProgramContainer(self.profiles) 1035 | self.lp.add_demands(GridDemand(DEMAND)) 1036 | 1037 | self.solar = GridSource(SOLAR, 1e6, 1e6) 1038 | 1039 | def testRandomSawToothRecStorage(self): 1040 | lp = self.lp 1041 | solar = self.solar 1042 | lp.add_nondispatchable_sources(solar) 1043 | storage = GridRecStorage(STORAGE, 0) 1044 | lp.add_storage(storage) 1045 | 1046 | for storage_efficiency in np.linspace(0.1, 1.0, 4): 1047 | storage.storage_efficiency = storage_efficiency 1048 | self.assertTrue(lp.solve()) 1049 | 1050 | # build up storage profile 1051 | demand_profile = self.demand_profile 1052 | golden_storage_profile = [] 1053 | last_storage_value = 0.0 1054 | for d in reversed(demand_profile): 1055 | next_storage_value = (last_storage_value + d) / storage_efficiency 1056 | golden_storage_profile.append(next_storage_value) 1057 | last_storage_value = next_storage_value 1058 | 1059 | golden_storage_profile.reverse() 1060 | # First generation comes from solar, so set golden_storage[0] = 0. 1061 | golden_storage_profile[0] = 0 1062 | golden_solar_profile = self.impulse * golden_storage_profile[1] 1063 | 1064 | npt.assert_allclose(solar.get_solution_values(), 1065 | golden_solar_profile) 1066 | 1067 | npt.assert_allclose(storage.get_solution_values(), 1068 | golden_storage_profile, 1069 | atol=1e-7) 1070 | 1071 | 1072 | class RpsTest(TwoTimeSliceTest): 1073 | """Test RPS constraints.""" 1074 | 1075 | def testCombinedSolarWindNgRps(self): 1076 | """Sweep RPS.""" 1077 | 1078 | solar = GridSource(SOLAR, 2.0e6, 0, is_rps_source=True) 1079 | wind = GridSource(WIND, 4.0e6, 0, is_rps_source=True) 1080 | ng = GridSource(NG, 0, 1.0e6) 1081 | 1082 | lp = self.lp 1083 | lp.add_dispatchable_sources(ng) 1084 | lp.add_nondispatchable_sources(solar, wind) 1085 | 1086 | # As rps is swept, it will fill RPS requirement with cheaper solar 1087 | # first then wind. NG is cheapest but not in RPS so it will fill 1088 | # in the blanks. 1089 | 1090 | # Wind is really expensive so check and make sure the LP doesn't 1091 | # cheat by requesting more cheaper solar than it can actually use 1092 | # in order to satisfy rps. Instead, after it's maxed out on 1093 | # solar, it has to use the expensive wind. 1094 | 1095 | for rps in np.arange(0, 1.2, 0.1): 1096 | lp.rps_percent = rps * 100.0 1097 | if lp.solve(): 1098 | self.assertTrue(rps <= 1.0) 1099 | 1100 | solar_nameplate = rps * 2 if rps <= 0.5 else 1.0 1101 | wind_nameplate = (rps - 0.5) * 2 if rps > 0.5 else 0.0 1102 | 1103 | solar_golden_profile = self.profiles[SOLAR] * solar_nameplate 1104 | wind_golden_profile = self.profiles[WIND] * wind_nameplate 1105 | 1106 | ng_golden_profile = (self.profiles[DEMAND] - 1107 | solar_golden_profile - 1108 | wind_golden_profile) 1109 | 1110 | npt.assert_almost_equal(solar.get_solution_values(), 1111 | solar_golden_profile) 1112 | 1113 | npt.assert_almost_equal(wind.get_solution_values(), 1114 | wind_golden_profile) 1115 | 1116 | npt.assert_almost_equal(ng.get_solution_values(), 1117 | ng_golden_profile) 1118 | 1119 | else: 1120 | # Ensure lp doesn't solve if rps > 1.0 1121 | self.assertTrue(rps > 1.0) 1122 | 1123 | def testRpsStorage(self): 1124 | """Storage credits rps if timeslice exceeds demand.""" 1125 | 1126 | lp = self.lp 1127 | wind = GridSource(WIND, 2.0e6, 0, is_rps_source=True) 1128 | ng = GridSource(NG, 0, 1.0e6) 1129 | storage = GridRecStorage(STORAGE, 1, 1, 1) 1130 | 1131 | lp.add_nondispatchable_sources(wind) 1132 | lp.add_dispatchable_sources(ng) 1133 | lp.add_storage(storage) 1134 | 1135 | demand_at_0 = self.profiles[DEMAND][0] 1136 | demand_at_1 = self.profiles[DEMAND][1] 1137 | 1138 | total = sum(self.profiles[DEMAND]) 1139 | for rps in range(0, 120, 10): 1140 | lp.rps_percent = rps 1141 | 1142 | rps_total = total * rps / 100.0 1143 | 1144 | # Wind is only on for one time-slice. 1145 | wind_at_0 = rps_total 1146 | wind_nameplate = wind_at_0 1147 | golden_wind = wind_nameplate * self.profiles[WIND] 1148 | 1149 | # Either we charge at 0, or ng fills remaining. 1150 | if wind_at_0 >= demand_at_0: 1151 | storage_at_0 = wind_at_0 - demand_at_0 1152 | ng_at_0 = 0 1153 | else: 1154 | storage_at_0 = 0 1155 | ng_at_0 = demand_at_0 - wind_at_0 1156 | 1157 | # Discharge everything at 1. 1158 | storage_discharge_power = storage_at_0 1159 | ng_at_1 = demand_at_1 - storage_discharge_power 1160 | 1161 | if lp.solve(): 1162 | self.assertTrue(rps <= 100) 1163 | npt.assert_almost_equal(wind.get_solution_values(), 1164 | golden_wind) 1165 | 1166 | npt.assert_almost_equal(ng.get_solution_values(), 1167 | np.array([ng_at_0, ng_at_1])) 1168 | 1169 | # Storage at t=1 shows what charged at t=0. 1170 | npt.assert_almost_equal(storage.get_solution_values(), 1171 | np.array([0.0, storage_at_0])) 1172 | else: 1173 | # Verify no convergence because we asked for a ridiculous rps number. 1174 | self.assertTrue(rps > 100) 1175 | 1176 | 1177 | class FourTimeSliceRpsTest(unittest.TestCase): 1178 | 1179 | def setUp(self): 1180 | self.demand_profile = np.array([0.0, 50, 50, 0.0]) 1181 | 1182 | rng = pd.date_range('1/1/2011', periods=len(self.demand_profile), freq='H') 1183 | 1184 | df = pd.DataFrame.from_dict( 1185 | {DEMAND: self.demand_profile, 1186 | SOLAR: np.array([0.0, 0.0, 0.0, 1.0]), 1187 | WIND: np.array([1.0, 0.0, 0.0, 0.0]), 1188 | TIME: rng} 1189 | ) 1190 | 1191 | self.profiles = df.set_index(TIME, 1192 | verify_integrity=True) 1193 | 1194 | self.lp = LinearProgramContainer(self.profiles) 1195 | self.lp.add_demands(GridDemand(DEMAND)) 1196 | 1197 | def testCircularRecAccounting(self): 1198 | """Verify that RECs get stored and accounted for properly.""" 1199 | lp = self.lp 1200 | solar = GridSource(SOLAR, 2e6, 2e6, is_rps_source=True) 1201 | wind = GridSource(WIND, 1e6, 1e6) 1202 | storage = GridRecStorage(STORAGE, 1, 1, 1, discharge_efficiency=0.5) 1203 | 1204 | lp.add_nondispatchable_sources(solar, wind) 1205 | lp.add_storage(storage) 1206 | 1207 | for rps in np.arange(0, 110, 10): 1208 | lp.rps_percent = rps 1209 | self.assertTrue(lp.solve()) 1210 | 1211 | npt.assert_almost_equal(solar.get_solution_values(), 1212 | self.profiles[SOLAR].values * 1213 | rps / storage.discharge_efficiency) 1214 | 1215 | npt.assert_almost_equal(wind.get_solution_values(), 1216 | self.profiles[WIND] * 1217 | (100 - rps) / storage.discharge_efficiency) 1218 | 1219 | rps_credit = np.array([v.solution_value() 1220 | for v in lp.rps_credit_variables[0]]) 1221 | self.assertAlmostEqual(sum(rps_credit), rps) 1222 | 1223 | rec_storage = storage.rec_storage 1224 | no_rec_storage = storage.no_rec_storage 1225 | 1226 | npt.assert_almost_equal(rps_credit, 1227 | rec_storage.source.get_solution_values() * 1228 | storage.discharge_efficiency) 1229 | 1230 | npt.assert_almost_equal(self.profiles[DEMAND] - rps_credit, 1231 | no_rec_storage.source.get_solution_values() * 1232 | storage.discharge_efficiency) 1233 | 1234 | 1235 | class MockPostProcessingGridSourceSolver(object): 1236 | """Class which modifies solution_values to verify post processing tests. 1237 | 1238 | Class must be instantiated after LinearProgramContainer.solve(). 1239 | Then solution_values may be manipulated to induce errors. 1240 | 1241 | """ 1242 | 1243 | def __init__(self, grid_source): 1244 | self.original_solution_values = np.array(grid_source.get_solution_values()) 1245 | self.reset() 1246 | grid_source.solver = self 1247 | 1248 | def reset(self): 1249 | self.solution_values = np.array(self.original_solution_values) 1250 | 1251 | def get_solution_values(self): 1252 | return self.solution_values 1253 | 1254 | 1255 | class PostProcessingTest(TwoTimeSliceTest): 1256 | 1257 | def testPostProcessing(self): 1258 | lp = self.lp 1259 | wind = GridSource(WIND, 2.0e6, 0, is_rps_source=True) 1260 | ng = GridSource(NG, 1.0e6, 1.0e6) 1261 | storage = GridStorage(STORAGE, 1, 1, 1) 1262 | 1263 | lp.add_nondispatchable_sources(wind) 1264 | lp.add_dispatchable_sources(ng) 1265 | lp.add_storage(storage) 1266 | 1267 | lp.rps_percent = 20 1268 | self.assertTrue(lp.solve()) 1269 | 1270 | wind_solution = MockPostProcessingGridSourceSolver(wind) 1271 | storage_sink_solution = MockPostProcessingGridSourceSolver(storage.sink) 1272 | 1273 | wind_solution.solution_values -= 0.1 1274 | 1275 | with self.assertRaises(DemandNotSatisfiedError): 1276 | lp._post_process() 1277 | 1278 | # After reset it shouldn't error out. 1279 | wind_solution.reset() 1280 | try: 1281 | lp._post_process() 1282 | except RuntimeError: 1283 | self.fail() 1284 | 1285 | storage_sink_solution.solution_values += 20 1286 | 1287 | with self.assertRaises(DemandNotSatisfiedError): 1288 | lp._post_process() 1289 | 1290 | # After reset it shouldn't error out. 1291 | storage_sink_solution.reset() 1292 | try: 1293 | lp._post_process() 1294 | except RuntimeError: 1295 | self.fail() 1296 | 1297 | lp.rps_demand *= 100 1298 | with self.assertRaises(RpsPercentNotMetError): 1299 | lp._post_process() 1300 | 1301 | 1302 | class ExtrapolateCostsTest(unittest.TestCase): 1303 | 1304 | def testZeroCostOfMoney(self): 1305 | self.assertEqual(gslp.extrapolate_cost(1.0, 1306 | 0.0, 1307 | 1, 1308 | 30), 1309 | 1.0) 1310 | 1311 | 1312 | if __name__ == '__main__': 1313 | unittest.main() 1314 | -------------------------------------------------------------------------------- /gridsim/grid_sim_linear_program.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Simulate cost-optimal electrical grid construction under different policies. 16 | 17 | Code contains GridElements: Power Sources, Demands and Storage. Grid 18 | Elements are placed in different grid regions. Grid regions are 19 | separated from each other so only sources with grid_region_id == x can 20 | power Demands with grid_region_id == x 21 | 22 | The costs of constructing GridElements are based upon: 23 | nameplate_unit_cost: The cost to build one unit (e.g. Megawatt) of power. 24 | variable_unit_cost: The cost to provide one unit of power over time. 25 | (e.g. Megawatt-Hour) 26 | 27 | The code simulates the grid over multiple time-slices. e.g. Hourly 28 | over a one year period which would map to 24 * 365 = 8760 time-slices. 29 | 30 | The code is based upon a linear-program which contains: 31 | 32 | - An objective which is to minimize costs. 33 | - Constraints which must be met before the solution can converge. 34 | - conserve_power_constraint: Ensure that sum(power[t]) >= 35 | demand[t] for all t in each grid-region 36 | 37 | This code will work with any set of consistent units. For the 38 | purposes of documentation, the units chosen are: 39 | 40 | Power: Megawatts 41 | Time: Hours 42 | (Derived) Energy = Power * Time => Megawatt-Hours 43 | Cost: Dollars ($) 44 | CO2 Emissions: Tonnes 45 | 46 | (Derived) CO2 Emitted per Energy => Tonnes / Megawatt-Hours 47 | Carbon Tax: $ / Tonnes 48 | 49 | """ 50 | 51 | import logging 52 | import numpy as np 53 | 54 | from ortools.linear_solver import pywraplp 55 | 56 | 57 | class GridSimError(RuntimeError): 58 | pass 59 | 60 | 61 | class DemandNotSatisfiedError(GridSimError): 62 | pass 63 | 64 | 65 | class RpsExceedsDemandError(GridSimError): 66 | pass 67 | 68 | 69 | class RpsCreditExceedsSourcesError(GridSimError): 70 | pass 71 | 72 | 73 | class StorageExceedsDemandError(GridSimError): 74 | pass 75 | 76 | 77 | class RpsPercentNotMetError(GridSimError): 78 | pass 79 | 80 | 81 | class Constraint(object): 82 | """Holds an LP Constraint object with extra debugging information. 83 | 84 | Attributes: 85 | constraint: underlying pywraplp.Constraint object 86 | name: name of constraint 87 | formula: hashtable that maps names of variables to coefficients 88 | 89 | pywraplp.Constraint doesn't surface a list of variables/coefficients, so 90 | we have to keep track ourselves. 91 | """ 92 | 93 | def __init__(self, lp, lower_bound, upper_bound, name=None, debug=False): 94 | """Initializes Constraint. 95 | 96 | Args: 97 | lp: LinearProgramContainer that wraps the LP solver which 98 | creates the constraint. 99 | lower_bound: (float) Lower bound on product between coeffs and variables. 100 | upper_bound: (float) Upper bound on product between coeffs and variables. 101 | name: Optional human readable string. 102 | debug: Boolean which if set, logs constraint info. 103 | """ 104 | 105 | self.constraint = lp.solver.Constraint(lower_bound, upper_bound) 106 | self.name = name 107 | self.formula = {} 108 | self.debug = debug 109 | 110 | if self.debug: 111 | logging.debug('CONSTRAINT: %f <= %s <= %f', 112 | lower_bound, name, upper_bound) 113 | 114 | def set_coefficient(self, variable, coefficient): 115 | """Adds variable * coefficient to LP Coefficient. 116 | 117 | Wraps pywrap.SetCoefficient(variable, coefficient) method and 118 | saves variable, coefficient to formula dict. 119 | 120 | After calling this method, Objective += variable * coefficient 121 | 122 | Args: 123 | variable: (Lp Variable) The Variable multiplicand. 124 | coefficient: (float) The coefficient multiplicand. 125 | 126 | """ 127 | 128 | self.constraint.SetCoefficient(variable, coefficient) 129 | self.formula[variable.name()] = coefficient 130 | 131 | if self.debug: 132 | logging.debug('%s += %s * %f', self.name, variable.name(), coefficient) 133 | 134 | 135 | class Objective(object): 136 | """Holds an LP Objective object with extra debugging information. 137 | 138 | Attributes: 139 | objective: Underlying pywraplp.Objective object. 140 | """ 141 | 142 | def __init__(self, lp, minimize=True): 143 | """Initializes Objective. 144 | 145 | Args: 146 | lp: LinearProgramContainer that wraps the LP solver which 147 | creates the Objective. 148 | minimize: boolean, True if objective should be minimized 149 | otherwise objective is maximizied. 150 | """ 151 | self.objective = lp.solver.Objective() 152 | self.formula = {} 153 | if minimize: 154 | self.objective.SetMinimization() 155 | else: 156 | self.objective.SetMaximization() 157 | 158 | def set_coefficient(self, variable, coefficient): 159 | """Adds variable * coefficient to LP Objective. 160 | 161 | Wraps pywrap.SetCoefficient(variable, coefficient) method and 162 | saves variable, coefficient to formula dict. 163 | 164 | After calling this method, Objective += variable * coefficient 165 | 166 | Args: 167 | variable: (Lp Variable) The Variable multiplicand. 168 | coefficient: (float) The coefficient multiplicand. 169 | 170 | """ 171 | 172 | self.objective.SetCoefficient(variable, coefficient) 173 | self.formula[variable.name()] = coefficient 174 | 175 | def value(self): 176 | return self.objective.Value() 177 | 178 | 179 | class GridDemand(object): 180 | """Simple place-holder object which represents load on the grid.""" 181 | 182 | def __init__(self, 183 | name, 184 | grid_region_id=0): 185 | """Initializes GridDemand object. 186 | 187 | Args: 188 | name: name of the demand object 189 | 190 | grid_region_id: An int specifying the grid region of the demand. 191 | Only sources with the same grid_region_id can power this demand. 192 | 193 | """ 194 | 195 | self.name = name 196 | self.grid_region_id = grid_region_id 197 | 198 | 199 | class GridSource(object): 200 | """Denotes Costs, co2, region, power and energy limitations of a power source. 201 | 202 | Grid Sources may either be dispatchable or non-dispatchable. 203 | - Dispatchable sources may power at any time, e.g. fossil fuel plants. 204 | - Non-dispatchable sources are dependent on the environment to 205 | generate power. e.g. Solar or Wind plants. 206 | 207 | If there is a time-slice power profile indexed by the same name as 208 | this source in LinearProgramContainer.profiles. The source is 209 | considered Non-dispatchable. Otherwise, it is considered dispatchable. 210 | 211 | Attributes: 212 | name: (str) name of the object. 213 | nameplate_unit_cost: (float) Cost to build a unit of 214 | dispatchable power. ($ / Megawatt of capacity) 215 | 216 | variable_unit_cost: (float) Cost to supply a unit of dispatchable power 217 | per time. ($ / Megawatt-Hour) 218 | 219 | grid_region_id: An int specifying the grid region of the source. 220 | Only demands with the same grid_region_id can sink the power 221 | from this source. 222 | 223 | max_power: (float) Optional Maximum power which object can supply. 224 | (Megawatt). Set < 0 if there is no limit. 225 | 226 | max_energy: (float) Optional maximum energy which object can 227 | supply. (Megawatt-Hours) Set < 0 if there is no limit. 228 | 229 | co2_per_electrical_energy: (float) (Tonnes of CO2 / Megawatt Hour). 230 | 231 | power_coefficient: (float) ratio of how much power is supplied by 232 | object vs. how much power gets on the grid. 0 < 233 | power_coefficient < 1. Nominally 1.0. 234 | 235 | is_rps_source: Boolean which denotes if the source is included 236 | in the Renewable Portfolio Standard. 237 | 238 | solver: Either a _GridSourceDispatchableSolver or 239 | _GridSourceNonDispatchableSolver. Used to setup LP 240 | Constraints, Objectives and variables for the source and to 241 | report results. 242 | 243 | timeslice_variables: An array of LP variables, one per time-slice 244 | of simulation. Array is mapped so that variable for 245 | time-slice t is at index t. 246 | e.g. 247 | Variable for first time-slice is timeslice_variable[0]. 248 | Variable for last time-slice is timeslice_variable[-1]. 249 | Variable for time-slice at time t is timeslice_variable[t]. 250 | Only gets declared if GridSource is a DispatchableSource. 251 | 252 | nameplate_variable: LP variable representing the nameplate or 253 | maximum power the GridSource can output at any given 254 | time. 255 | 256 | """ 257 | 258 | def __init__( 259 | self, 260 | name, 261 | nameplate_unit_cost, 262 | variable_unit_cost, 263 | grid_region_id=0, 264 | max_power=-1.0, 265 | max_energy=-1.0, 266 | co2_per_electrical_energy=0, 267 | power_coefficient=1.0, 268 | is_rps_source=False 269 | ): 270 | """Sets characteristics of a GridSource object. 271 | 272 | Args: 273 | name: (str) name of the object. 274 | nameplate_unit_cost: (float) Cost to build a unit of 275 | dispatchable power. ($ / Megawatt of capacity) 276 | 277 | variable_unit_cost: (float) Cost to supply a unit of dispatchable power 278 | per time. ($ / Megawatt-Hour) 279 | 280 | grid_region_id: An int specifying the grid region of the demand. 281 | Only demands with the same grid_region_id can sink the power 282 | from this source. 283 | 284 | max_power: (float) Maximum power which object can supply. (Megawatt) 285 | max_energy: (float) Maximum energy which object can 286 | supply. (Megawatt-Hours) 287 | 288 | co2_per_electrical_energy: (float) (Tonnes of CO2 / Megawatt Hour). 289 | power_coefficient: (float) ratio of how much power is supplied by 290 | object vs. how much power gets on the grid. 0 < 291 | power_coefficient < 1. Nominally 1.0. 292 | is_rps_source: Boolean which denotes if the source is included 293 | in the Renewable Portfolio Standard. 294 | """ 295 | 296 | self.name = name 297 | self.nameplate_unit_cost = nameplate_unit_cost 298 | self.variable_unit_cost = variable_unit_cost 299 | self.max_energy = max_energy 300 | self.max_power = max_power 301 | self.grid_region_id = grid_region_id 302 | self.co2_per_electrical_energy = co2_per_electrical_energy 303 | self.power_coefficient = power_coefficient 304 | self.is_rps_source = is_rps_source 305 | 306 | self.solver = None 307 | 308 | self.timeslice_variables = None 309 | self.nameplate_variable = None 310 | 311 | def configure_lp_variables_and_constraints(self, lp): 312 | """Declare lp variables, and set constraints. 313 | 314 | Args: 315 | lp: The LinearProgramContainer. 316 | 317 | Defers to self.solver which properly configures variables and 318 | constraints in this object. 319 | 320 | See Also: 321 | _GridSourceDispatchableSolver, _GridSourceNonDispatchableSolver 322 | 323 | """ 324 | self.solver.configure_lp_variables_and_constraints(lp) 325 | 326 | def post_process(self, lp): 327 | """Update lp post_processing result variables. 328 | 329 | This is done post lp.solve() so that sanity data checks can be done 330 | on RPS before returning results. 331 | 332 | Args: 333 | lp: The LinearProgramContainer where the post processing variables reside. 334 | """ 335 | 336 | if lp.rps_percent > 0.0 and self.is_rps_source: 337 | lp.rps_total[self.grid_region_id] += self.get_solution_values() 338 | else: 339 | lp.non_rps_total[self.grid_region_id] += self.get_solution_values() 340 | 341 | def get_solution_values(self): 342 | """Gets the linear program solver results. 343 | 344 | Must be called after lp.solve() to ensure solver has properly 345 | converged and has generated results. 346 | 347 | Returns: 348 | np.array of solutions for each timeslice variable. 349 | 350 | """ 351 | 352 | return self.solver.get_solution_values() 353 | 354 | def get_nameplate_solution_value(self): 355 | """Gets the linear program solver results for nameplate. 356 | 357 | Must be called after lp.solve() to ensure solver has properly 358 | converged and has generated results. 359 | 360 | Raises: 361 | RuntimeError: If called before LinearProgramContainer.solve(). 362 | 363 | Returns: 364 | Float value representing solved nameplate value. 365 | 366 | """ 367 | nameplate_variable = self.nameplate_variable 368 | 369 | if nameplate_variable is None: 370 | raise RuntimeError('Get_nameplate_solution_value called before solve().') 371 | 372 | return nameplate_variable.solution_value() 373 | 374 | 375 | class _GridSourceDispatchableSolver(object): 376 | """Power Source which can provide power at any time. 377 | 378 | Attributes: 379 | source: GridSource object where self generates LP variables 380 | """ 381 | 382 | def __init__(self, source): 383 | self.source = source 384 | 385 | def configure_lp_variables_and_constraints(self, lp): 386 | """Declare lp variables, and set constraints in grid_source. 387 | 388 | Args: 389 | lp: The LinearProgramContainer. 390 | 391 | Variables Declared include: 392 | - timeslice variables: represent how much power the source is 393 | outputting at each time-slice. 394 | - nameplate variable: represents the maximum power sourced. 395 | 396 | The values of these variables are solved by the linear program to 397 | optimize costs subject to some constraints. 398 | 399 | The overall objective is to minimize cost. Herein, the overall 400 | cost is increased by: 401 | - nameplate cost: nameplate_unit_cost * nameplate variable 402 | - variable cost: variable_unit_cost * sum(timeslice_variables) 403 | - carbon cost: lp.carbon_tax * sum(timeslice_variables) * 404 | co2_per_electrical_energy 405 | 406 | Since variable and carbon costs accrue on a periodic basis, we 407 | multiply them by lp.cost_of_money to make periodic and 408 | one-time costs comparable. 409 | 410 | Constraints created / modified here include: 411 | - Maximum Energy: Ensure sum timeslice-variables < max_energy if 412 | self.max_energy >= 0. 413 | 414 | This constraint is only for sources where there are limits 415 | to the total amount of generation which can be built. 416 | E.g. There are only a limited number of places where one can 417 | build hydropower. 418 | 419 | - Maximum Power: Ensure no timeslice-variables > max_power if 420 | self.max_power is >= 0. 421 | 422 | This constraint is only for sources where there are limits 423 | to the maximum amount of power which can be built. 424 | E.g. hydropower which can only discharge at a maximum rate. 425 | 426 | - Conserve Power: Ensure that sum(power) > demand for all 427 | time-slices. Colloquially called "Keeping the Lights on." 428 | 429 | - Ensure nameplate variable > power(t) for all t. We must make 430 | sure that we've priced out a plant which can supply the 431 | requested power. 432 | 433 | """ 434 | 435 | source = self.source 436 | 437 | # setup LP variables. 438 | source.timeslice_variables = lp.declare_timeslice_variables( 439 | source.name, 440 | source.grid_region_id) 441 | 442 | source.nameplate_variable = lp.declare_nameplate_variable( 443 | source.name, 444 | source.grid_region_id) 445 | 446 | solver = lp.solver 447 | 448 | # Configure maximum energy if it is >= 0. Otherwise do not 449 | # create a constraint. 450 | max_energy_constraint = (lp.constraint(0.0, source.max_energy) 451 | if source.max_energy >= 0 else None) 452 | 453 | # Configure maximum nameplate if it is >= 0. Otherwise do not 454 | # create a constraint. 455 | max_power = source.max_power 456 | if max_power >= 0: 457 | lp.constraint(0.0, max_power).set_coefficient( 458 | source.nameplate_variable, 1.0 459 | ) 460 | 461 | # Total_cost includes nameplate cost. 462 | cost_objective = lp.minimize_costs_objective 463 | cost_objective.set_coefficient(source.nameplate_variable, 464 | source.nameplate_unit_cost) 465 | 466 | # Add timeslice variables to coefficients. 467 | for t, var in enumerate(source.timeslice_variables): 468 | 469 | # Total_cost also includes variable and carbon cost. 470 | variable_coef = ((source.variable_unit_cost + 471 | source.co2_per_electrical_energy * lp.carbon_tax) * 472 | lp.cost_of_money) 473 | cost_objective.set_coefficient(var, variable_coef) 474 | 475 | # Keep the lights on at all times. Power_coefficient is usually 476 | # 1.0, but is -1.0 for GridStorage.sink and discharge_efficiency 477 | # for GridStorage.source. 478 | lp.conserve_power_constraint[source.grid_region_id][t].set_coefficient( 479 | var, source.power_coefficient) 480 | 481 | # Constrain rps_credit if needed. 482 | if source.is_rps_source: 483 | lp.rps_source_constraints[source.grid_region_id][t].set_coefficient( 484 | var, source.power_coefficient) 485 | 486 | # Ensure total energy is less than source.max_energy. 487 | if max_energy_constraint is not None: 488 | max_energy_constraint.set_coefficient(var, 1.0) 489 | 490 | # Ensure power doesn't exceed source.max_power. 491 | if max_power >= 0: 492 | lp.constraint(0.0, max_power).set_coefficient(var, 1.0) 493 | 494 | # Nameplate must be bigger than largest power. 495 | # If nameplate_unit_cost > 0, Cost Optimization will push 496 | # Nameplate near max(timeslice_variables). 497 | 498 | nameplate_constraint = lp.constraint(0.0, solver.infinity()) 499 | nameplate_constraint.set_coefficient(var, -1.0) 500 | nameplate_constraint.set_coefficient(source.nameplate_variable, 1.0) 501 | 502 | # Constrain maximum nameplate if max_power is set. 503 | if source.max_power >= 0: 504 | lp.constraint(0.0, source.max_power).set_coefficient( 505 | source.nameplate_variable, 506 | 1.0) 507 | 508 | def get_solution_values(self): 509 | """Gets the linear program solver results. 510 | 511 | Must be called after lp.solve() to ensure solver has properly 512 | converged and has generated results. 513 | 514 | Raises: 515 | RuntimeError: If called before LinearProgramContainer.solve(). 516 | 517 | Returns: 518 | np.array of solutions for each timeslice variable. 519 | 520 | """ 521 | 522 | timeslice_variables = self.source.timeslice_variables 523 | 524 | if timeslice_variables is None: 525 | raise RuntimeError('get_solution_values called before solve.') 526 | 527 | return np.array([v.solution_value() 528 | for v in timeslice_variables]) 529 | 530 | 531 | class _GridSourceNonDispatchableSolver(object): 532 | """Power Source which can provide nameplate multiple of its profile. 533 | 534 | Attributes: 535 | source: GridSource object where self generates LP variables 536 | profile: pandas Series which represents what fraction of the 537 | nameplate the source can provide at any given time. 538 | 539 | """ 540 | 541 | def __init__(self, source, profile): 542 | self.source = source 543 | 544 | # check profile isn't all zeros 545 | if (profile.values == 0.0).all(): 546 | raise ValueError('%s profile may not be all zero.' %source.name) 547 | 548 | self.profile = source.power_coefficient * profile / max(profile) 549 | 550 | def configure_lp_variables_and_constraints(self, lp): 551 | """Declare lp variables, and set constraints in grid_source. 552 | 553 | Args: 554 | lp: The LinearProgramContainer. 555 | 556 | Variables Declared include: 557 | - nameplate variable: represents the maximum power sourced. 558 | 559 | The values of these variables are solved by the linear program to 560 | optimize costs subject to some constraints. 561 | 562 | The overall objective is to minimize cost. Herein, the overall 563 | cost is increased by: 564 | - nameplate cost: nameplate_unit_cost * nameplate variable 565 | - variable cost: variable_unit_cost * nameplate variable * sum(profile) 566 | - carbon cost: lp.carbon_tax * nameplate variable * sum(profile) 567 | 568 | Since variable and carbon costs accrue on a yearly basis, we 569 | multiply them by lp.cost_of_money to make yearly and 570 | one-time costs comparable. 571 | 572 | Constraints created / modified here include: 573 | - Maximum Energy: Ensure nameplate * sum(profile) < max_energy if 574 | self.max_energy >= 0. 575 | 576 | This constraint is only for sources where there are limits 577 | to the total amount of generation which can be built. 578 | E.g. There are only a limited number of places where one can 579 | build hydropower. 580 | 581 | - Maximum Power: Ensure nameplate <= max_power if 582 | self.max_power >= 0. 583 | 584 | This constraint is only for sources where there are limits 585 | to the maximum amount of power which can be built. 586 | E.g. hydropower which can only discharge at a maximum rate. 587 | 588 | - Conserve Power: Ensure that sum(power) > demand for all 589 | time-slices. Colloquially called "Keeping the Lights on." 590 | 591 | """ 592 | 593 | source = self.source 594 | 595 | # setup LP variables. 596 | source.nameplate_variable = lp.declare_nameplate_variable( 597 | source.name, 598 | source.grid_region_id) 599 | 600 | sum_profile = sum(self.profile) 601 | 602 | # Configure maximum energy if it is >= 0. Otherwise do not 603 | # create a constraint. 604 | if source.max_energy >= 0: 605 | lp.constraint(0.0, source.max_energy).set_coefficient( 606 | source.nameplate_variable, sum_profile) 607 | 608 | # Configure maximum energy if it is >= 0. Otherwise do not 609 | # create a constraint. 610 | max_power = source.max_power 611 | if max_power >= 0: 612 | lp.constraint(0.0, max_power).set_coefficient(source.nameplate_variable, 613 | 1.0) 614 | 615 | # Total_cost includes nameplate cost. 616 | cost_objective = lp.minimize_costs_objective 617 | 618 | cost_coefficient = source.nameplate_unit_cost + lp.cost_of_money * ( 619 | source.variable_unit_cost * sum_profile + 620 | source.co2_per_electrical_energy * sum_profile * lp.carbon_tax) 621 | 622 | cost_objective.set_coefficient(source.nameplate_variable, 623 | cost_coefficient) 624 | 625 | # Add timeslice variables to coefficients. 626 | for t, profile_t in enumerate(self.profile): 627 | 628 | # Keep the lights on at all times. 629 | try: 630 | constraint = lp.conserve_power_constraint[source.grid_region_id] 631 | except KeyError: 632 | raise KeyError('No Demand declared in grid_region %d.' % ( 633 | source.grid_region_id)) 634 | 635 | constraint[t].set_coefficient(source.nameplate_variable, profile_t) 636 | 637 | # Constrain rps_credit if needed. 638 | if source.is_rps_source: 639 | lp.rps_source_constraints[source.grid_region_id][t].set_coefficient( 640 | source.nameplate_variable, profile_t) 641 | 642 | def get_solution_values(self): 643 | """Gets the linear program solver results. 644 | 645 | Must be called after lp.solve() to ensure solver has properly 646 | converged and has generated results. 647 | 648 | Raises: 649 | RuntimeError: If called before LinearProgramContainer.solve(). 650 | 651 | Returns: 652 | np.array of solutions for each timeslice variable. 653 | 654 | """ 655 | 656 | nameplate_variable = self.source.nameplate_variable 657 | if nameplate_variable is None: 658 | raise RuntimeError('get_solution_values called before solve.') 659 | 660 | return nameplate_variable.solution_value() * self.profile.values 661 | 662 | 663 | class GridStorage(object): 664 | """Stores energy from the grid and returns it when needed subject to losses. 665 | 666 | Attributes: 667 | name: A string which is the name of the object. 668 | storage_nameplate_cost: A float which is the cost per nameplate of 669 | energy storage. E.g. The cost of batteries. 670 | charge_nameplate_cost: A float which is the cost per nameplate 671 | power to charge the storage. E.g. The rectifier cost to convert 672 | an AC grid to DC storage. 673 | discharge_nameplate_cost: A float which is the cost per nameplate 674 | power to recharge the grid. E.g. The cost of a power inverter to 675 | convert DC storage back to AC 676 | grid_region_id: An int specifying the grid region of the storage. 677 | The storage can only store energy generated by sources with the 678 | same grid_region_id. Only demands with the same grid_region_id 679 | can sink power from this. 680 | charge_efficiency: A float ranging from 0.0 - 1.0 which describes 681 | the energy loss between the grid and the storage element. 0.0 682 | means complete loss, 1.0 means no loss. 683 | storage_efficiency: A float ranging from 0.0 - 1.0 which describes 684 | how much stored energy remains from previous stored energy after 685 | one time-cycle. 1.0 means no loss. 0.0 means all stored energy 686 | is lost. 687 | discharge_efficiency: A float ranging from 0.0 - 1.0 which describes 688 | the energy loss between storage and grid when recharging the grid. 689 | 0.0 means complete loss, 1.0 means no loss. 690 | max_charge_power: A float which represents the maximum power that 691 | can charge storage (calculated before any efficiency losses.). 692 | A value < 0 means there is no charge power limit. 693 | max_discharge_power: A float which represents the maximum power 694 | that can discharge storage (calculated before any efficiency 695 | losses.). A value < 0 means there is no discharge power limit. 696 | max_storage: An optional float which represents the maximum energy 697 | that can be stored. A value < 0 means there is no maximum 698 | storage limit. 699 | is_rps: Boolean; if true, keeps track of rps_credit as storage is 700 | charged / discharged. Amount charging[t] is subtracted from 701 | rps_credit[t] from rps_credit[t]. Amount discharging[t] is 702 | added to rps_credit[t]. If false, no rps_credits are adjusted. 703 | """ 704 | 705 | def __init__( 706 | self, 707 | name, 708 | 709 | storage_nameplate_cost, 710 | charge_nameplate_cost=0.0, 711 | discharge_nameplate_cost=0.0, 712 | 713 | grid_region_id=0, 714 | 715 | charge_efficiency=1.0, 716 | storage_efficiency=1.0, 717 | discharge_efficiency=1.0, 718 | 719 | max_charge_power=-1, 720 | max_discharge_power=-1, 721 | max_storage=-1, 722 | is_rps=False 723 | ): 724 | 725 | self.name = name 726 | self.storage_nameplate_cost = storage_nameplate_cost 727 | self.charge_nameplate_cost = charge_nameplate_cost 728 | self.discharge_nameplate_cost = discharge_nameplate_cost 729 | 730 | self.grid_region_id = grid_region_id 731 | self.charge_efficiency = charge_efficiency 732 | self.storage_efficiency = storage_efficiency 733 | self.discharge_efficiency = discharge_efficiency 734 | 735 | self.max_charge_power = max_charge_power 736 | self.max_discharge_power = max_discharge_power 737 | self.max_storage = max_storage 738 | self.is_rps = is_rps 739 | 740 | # Sink is a power element which sinks from the grid into storage. 741 | # Source is a power element which sources to the grid from storage. 742 | # Both are constructed in configure_lp_variables_and_constraints 743 | 744 | self.sink = None 745 | self.source = None 746 | 747 | def configure_lp_variables_and_constraints(self, lp): 748 | """Declare lp variables, and set constraints. 749 | 750 | Args: 751 | lp: LinearProgramContainer, contains lp solver and constraints. 752 | """ 753 | 754 | # Set up LP variables. 755 | self.energy_variables = lp.declare_timeslice_variables( 756 | self.name, 757 | self.grid_region_id 758 | ) 759 | 760 | if self.storage_nameplate_cost: 761 | self.energy_nameplate = lp.declare_nameplate_variable( 762 | self.name, 763 | self.grid_region_id 764 | ) 765 | 766 | # Set up source and configure LP variables. 767 | self.source = GridSource( 768 | name=self.name + ' source', 769 | nameplate_unit_cost=self.discharge_nameplate_cost, 770 | variable_unit_cost=0.0, 771 | grid_region_id=self.grid_region_id, 772 | max_power=self.max_discharge_power, 773 | co2_per_electrical_energy=0.0, 774 | power_coefficient=self.discharge_efficiency, 775 | is_rps_source=self.is_rps 776 | ) 777 | self.source.solver = _GridSourceDispatchableSolver(self.source) 778 | self.source.configure_lp_variables_and_constraints(lp) 779 | 780 | # Set up sink and configure LP variables. 781 | self.sink = GridSource( 782 | name=self.name + ' sink', 783 | nameplate_unit_cost=self.discharge_nameplate_cost, 784 | variable_unit_cost=0.0, 785 | grid_region_id=self.grid_region_id, 786 | max_power=self.max_charge_power, 787 | co2_per_electrical_energy=0.0, 788 | power_coefficient=-1.0, 789 | is_rps_source=self.is_rps 790 | ) 791 | self.sink.solver = _GridSourceDispatchableSolver(self.sink) 792 | self.sink.configure_lp_variables_and_constraints(lp) 793 | 794 | # Add energy nameplate costs to the objective. Other costs are 795 | # added by source/sink.configure_lp_variables_and_constraints. 796 | if self.storage_nameplate_cost: 797 | nameplate = self.energy_nameplate 798 | lp.minimize_costs_objective.set_coefficient(nameplate, 799 | self.storage_nameplate_cost) 800 | 801 | # Constrain Energy Storage to be Energy Last time plus sink minus source. 802 | # Storage is circular so variables at t=0 depend on variables at t=-1 803 | # which is equivalent to last value in python indexing scheme. 804 | variables = self.energy_variables 805 | for t in lp.time_index_iterable: 806 | # Ce = charge_efficiency, 807 | # Se = storage_efficiency. 808 | # Stored[i] = se * Stored[i-1] + ce * sink[i-1] - source[i-1] 809 | # 0 = -Stored[i] + se * Stored[i-1] + ce * sink[i-1] - source[i-1] 810 | c = lp.constraint(0.0, 0.0) 811 | c.set_coefficient(variables[t], -1.0) # -Stored[i] 812 | c.set_coefficient(variables[t - 1], self.storage_efficiency) 813 | 814 | # Source and sink are relative to the grid, so opposite here: 815 | # Sink adds to storage, source subtracts from storage. 816 | c.set_coefficient(self.source.timeslice_variables[t - 1], -1.0) 817 | c.set_coefficient(self.sink.timeslice_variables[t - 1], 818 | self.charge_efficiency) 819 | 820 | # Ensure nameplate is larger than stored_value. 821 | if self.storage_nameplate_cost: 822 | nameplate_constraint = lp.constraint(0.0, lp.solver.infinity()) 823 | nameplate_constraint.set_coefficient(nameplate, 1.0) 824 | nameplate_constraint.set_coefficient(variables[t], -1.0) 825 | 826 | # Constrain maximum storage if max_storage >= 0 827 | if self.max_storage >= 0.0: 828 | max_storage_constraint = lp.constraint(0.0, self.max_storage) 829 | max_storage_constraint.set_coefficient(variables[t], 1.0) 830 | 831 | def post_process(self, lp): 832 | """Update lp post_processing result variables. 833 | 834 | This is done post lp.solve() so that sanity data checks can be done 835 | on RPS before returning results. 836 | 837 | Args: 838 | lp: The LinearProgramContainer where the post processing variables reside. 839 | """ 840 | 841 | sink_vals = self.sink.get_solution_values() 842 | source_vals = (self.source.get_solution_values() * 843 | self.discharge_efficiency) 844 | 845 | if self.is_rps: 846 | lp.rps_total[self.grid_region_id] += source_vals - sink_vals 847 | else: 848 | lp.non_rps_total[self.grid_region_id] += source_vals - sink_vals 849 | 850 | def get_nameplate_solution_value(self): 851 | """Gets the linear program solver results for nameplate. 852 | 853 | Must be called after lp.solve() to ensure solver has properly 854 | converged and has generated results. 855 | 856 | Raises: 857 | RuntimeError: If called before LinearProgramContainer.solve(). 858 | 859 | Returns: 860 | Float value representing solved nameplate value. 861 | 862 | """ 863 | if self.storage_nameplate_cost: 864 | nameplate_variable = self.energy_nameplate 865 | 866 | if nameplate_variable is None: 867 | raise RuntimeError( 868 | 'Get_nameplate_solution_value called before solve().') 869 | 870 | return nameplate_variable.solution_value() 871 | else: 872 | return max(self.get_solution_values()) 873 | 874 | def get_solution_values(self): 875 | """Gets the linear program solver results. 876 | 877 | Must be called after lp.solve() to ensure solver has properly 878 | converged and has generated results. 879 | 880 | Raises: 881 | RuntimeError: If called before LinearProgramContainer.solve(). 882 | 883 | Returns: 884 | np.array of solutions for each timeslice variable. 885 | 886 | """ 887 | 888 | timeslice_variables = self.energy_variables 889 | 890 | if timeslice_variables is None: 891 | raise RuntimeError('get_solution_values called before solve.') 892 | 893 | return np.array([v.solution_value() 894 | for v in timeslice_variables]) 895 | 896 | 897 | class GridRecStorage(object): 898 | """Stores energy from the grid and returns it when needed subject to losses. 899 | 900 | This is a wrapper around two GridStorage objects, one which stores 901 | "clean" energy (is_rps) and one which stores "dirty" energy (not 902 | is_rps). There is a need for both types of storage to keep track of 903 | renewable energy credits. 904 | 905 | Attributes: 906 | name: A string which is the name of the object. 907 | storage_nameplate_cost: A float which is the cost per nameplate of 908 | energy storage. E.g. The cost of batteries. 909 | charge_nameplate_cost: A float which is the cost per nameplate 910 | power to charge the storage. E.g. The rectifier cost to convert 911 | an AC grid to DC storage. 912 | discharge_nameplate_cost: A float which is the cost per nameplate 913 | power to recharge the grid. E.g. The cost of a power inverter to 914 | convert DC storage back to AC 915 | grid_region_id: An int specifying the grid region of the storage. 916 | The storage can only store energy generated by sources with the 917 | same grid_region_id. Only demands with the same grid_region_id 918 | can sink power from this. 919 | charge_efficiency: A float ranging from 0.0 - 1.0 which describes 920 | the energy loss between the grid and the storage element. 0.0 921 | means complete loss, 1.0 means no loss. 922 | storage_efficiency: A float ranging from 0.0 - 1.0 which describes 923 | how much stored energy remains from previous stored energy after 924 | one time-cycle. 1.0 means no loss. 0.0 means all stored energy 925 | is lost. 926 | discharge_efficiency: A float ranging from 0.0 - 1.0 which describes 927 | the energy loss between storage and grid when recharging the grid. 928 | 0.0 means complete loss, 1.0 means no loss. 929 | max_charge_power: A float which represents the maximum power that 930 | can charge storage (calculated before any efficiency losses.). 931 | A value < 0 means there is no charge power limit. 932 | max_discharge_power: A float which represents the maximum power 933 | that can discharge storage (calculated before any efficiency 934 | losses.). A value < 0 means there is no discharge power limit. 935 | max_storage: An optional float which represents the maximum energy 936 | that can be stored. A value < 0 means there is no maximum 937 | storage limit. 938 | 939 | rec_storage: GridStorage object which stores "clean" energy. 940 | no_rec_storage: GridStorage object which stores "dirty" energy. 941 | """ 942 | 943 | def __init__( 944 | self, 945 | name, 946 | 947 | storage_nameplate_cost, 948 | charge_nameplate_cost=0.0, 949 | discharge_nameplate_cost=0.0, 950 | 951 | grid_region_id=0, 952 | 953 | charge_efficiency=1.0, 954 | storage_efficiency=1.0, 955 | discharge_efficiency=1.0, 956 | 957 | max_charge_power=-1, 958 | max_discharge_power=-1, 959 | max_storage=-1, 960 | ): 961 | 962 | self.name = name 963 | self.storage_nameplate_cost = storage_nameplate_cost 964 | self.charge_nameplate_cost = charge_nameplate_cost 965 | self.discharge_nameplate_cost = discharge_nameplate_cost 966 | 967 | self.grid_region_id = grid_region_id 968 | self.charge_efficiency = charge_efficiency 969 | self.storage_efficiency = storage_efficiency 970 | self.discharge_efficiency = discharge_efficiency 971 | 972 | self.max_charge_power = max_charge_power 973 | self.max_discharge_power = max_discharge_power 974 | self.max_storage = max_storage 975 | 976 | self.rec_storage = None 977 | self.no_rec_storage = None 978 | 979 | def configure_lp_variables_and_constraints(self, lp): 980 | """Declare lp variables, and set constraints.""" 981 | 982 | # For rec_storage and no_rec_storage storage, set all costs to 0 983 | # and with no limits. Calculate costs and limits after 984 | # declaration. 985 | 986 | self.rec_storage = GridStorage( 987 | name=self.name+' REC_STORAGE', 988 | storage_nameplate_cost=0, 989 | grid_region_id=self.grid_region_id, 990 | charge_efficiency=self.charge_efficiency, 991 | discharge_efficiency=self.discharge_efficiency, 992 | storage_efficiency=self.storage_efficiency, 993 | is_rps=True) 994 | 995 | self.no_rec_storage = GridStorage( 996 | name=self.name+' NO_REC_STORAGE', 997 | storage_nameplate_cost=0, 998 | grid_region_id=self.grid_region_id, 999 | charge_efficiency=self.charge_efficiency, 1000 | discharge_efficiency=self.discharge_efficiency, 1001 | storage_efficiency=self.storage_efficiency, 1002 | is_rps=False) 1003 | 1004 | self.rec_storage.configure_lp_variables_and_constraints(lp) 1005 | self.no_rec_storage.configure_lp_variables_and_constraints(lp) 1006 | 1007 | # Calculate costs and limits based on the sum of both rec_storage 1008 | # and no_rec_storage. 1009 | 1010 | # Set up LP variables. 1011 | self.energy_variables = lp.declare_timeslice_variables( 1012 | self.name, 1013 | self.grid_region_id 1014 | ) 1015 | 1016 | self.energy_nameplate = lp.declare_nameplate_variable( 1017 | self.name, 1018 | self.grid_region_id 1019 | ) 1020 | 1021 | self.charge_nameplate = lp.declare_nameplate_variable( 1022 | self.name + ' charge nameplate', 1023 | self.grid_region_id 1024 | ) 1025 | 1026 | self.discharge_nameplate = lp.declare_nameplate_variable( 1027 | self.name + ' discharge nameplate', 1028 | self.grid_region_id 1029 | ) 1030 | 1031 | # Set limits if needed. 1032 | if self.max_storage >= 0: 1033 | lp.constraint(0.0, self.max_storage).set_coefficient( 1034 | self.energy_nameplate, 1.0) 1035 | 1036 | if self.max_charge_power >= 0: 1037 | lp.constraint(0.0, self.max_charge_power).set_coefficient( 1038 | self.charge_nameplate, 1.0) 1039 | 1040 | if self.max_discharge_power >= 0: 1041 | lp.constraint(0.0, self.max_discharge_power).set_coefficient( 1042 | self.discharge_nameplate, 1.0) 1043 | 1044 | # Add energy nameplate costs to the objective. 1045 | lp.minimize_costs_objective.set_coefficient(self.energy_nameplate, 1046 | self.storage_nameplate_cost) 1047 | lp.minimize_costs_objective.set_coefficient(self.charge_nameplate, 1048 | self.charge_nameplate_cost) 1049 | lp.minimize_costs_objective.set_coefficient(self.discharge_nameplate, 1050 | self.discharge_nameplate_cost) 1051 | 1052 | rec_storage_energy_variables = self.rec_storage.energy_variables 1053 | no_rec_storage_energy_variables = self.no_rec_storage.energy_variables 1054 | 1055 | for t in lp.time_index_iterable: 1056 | # Ensure nameplate is >= sum(stored_values)[t]. 1057 | nameplate_constraint = lp.constraint(0.0, lp.solver.infinity()) 1058 | nameplate_constraint.set_coefficient(self.energy_nameplate, 1.0) 1059 | nameplate_constraint.set_coefficient(rec_storage_energy_variables[t], 1060 | -1.0) 1061 | nameplate_constraint.set_coefficient(no_rec_storage_energy_variables[t], 1062 | -1.0) 1063 | 1064 | rec_storage_charge_variables = ( 1065 | self.rec_storage.sink.timeslice_variables) 1066 | no_rec_storage_charge_variables = ( 1067 | self.no_rec_storage.sink.timeslice_variables) 1068 | rec_storage_discharge_variables = ( 1069 | self.rec_storage.source.timeslice_variables) 1070 | no_rec_storage_discharge_variables = ( 1071 | self.no_rec_storage.source.timeslice_variables) 1072 | 1073 | max_charge_constraint = lp.constraint(0.0, lp.solver.infinity()) 1074 | max_charge_constraint.set_coefficient(self.charge_nameplate, 1.0) 1075 | max_charge_constraint.set_coefficient( 1076 | rec_storage_charge_variables[t], -1.0) 1077 | max_charge_constraint.set_coefficient( 1078 | no_rec_storage_charge_variables[t], -1.0) 1079 | max_charge_constraint.set_coefficient( 1080 | rec_storage_discharge_variables[t], 1.0) 1081 | max_charge_constraint.set_coefficient( 1082 | no_rec_storage_discharge_variables[t], 1.0) 1083 | 1084 | max_discharge_constraint = lp.constraint(0.0, lp.solver.infinity()) 1085 | max_discharge_constraint.set_coefficient(self.discharge_nameplate, 1.0) 1086 | max_discharge_constraint.set_coefficient( 1087 | rec_storage_charge_variables[t], 1.0) 1088 | max_discharge_constraint.set_coefficient( 1089 | no_rec_storage_charge_variables[t], 1.0) 1090 | max_discharge_constraint.set_coefficient( 1091 | rec_storage_discharge_variables[t], -1.0) 1092 | max_discharge_constraint.set_coefficient( 1093 | no_rec_storage_discharge_variables[t], -1.0) 1094 | 1095 | def get_solution_values(self): 1096 | return (self.rec_storage.get_solution_values() + 1097 | self.no_rec_storage.get_solution_values()) 1098 | 1099 | def get_source_solution_values(self): 1100 | return (self.rec_storage.source.get_solution_values() + 1101 | self.no_rec_storage.source.get_solution_values() - 1102 | self.rec_storage.sink.get_solution_values() - 1103 | self.no_rec_storage.sink.get_solution_values()) 1104 | 1105 | def get_sink_solution_values(self): 1106 | return -self.get_source_solution_values() 1107 | 1108 | def get_nameplate_solution_value(self): 1109 | """Gets the linear program solver results for nameplate. 1110 | 1111 | Must be called after lp.solve() to ensure solver has properly 1112 | converged and has generated results. 1113 | 1114 | Raises: 1115 | RuntimeError: If called before LinearProgramContainer.solve(). 1116 | 1117 | Returns: 1118 | Float value representing solved nameplate value. 1119 | 1120 | """ 1121 | if self.storage_nameplate_cost: 1122 | nameplate_variable = self.energy_nameplate 1123 | 1124 | if nameplate_variable is None: 1125 | raise RuntimeError( 1126 | 'Get_nameplate_solution_value called before solve().') 1127 | 1128 | return nameplate_variable.solution_value() 1129 | else: 1130 | return max(self.get_solution_values()) 1131 | 1132 | def post_process(self, lp): 1133 | self.rec_storage.post_process(lp) 1134 | self.no_rec_storage.post_process(lp) 1135 | 1136 | 1137 | class _GridTransmission(GridSource): 1138 | """Shuttles power from one time-zone to another.""" 1139 | 1140 | def __init__( 1141 | self, 1142 | name, 1143 | nameplate_unit_cost, 1144 | source_grid_region_id=0, 1145 | sink_grid_region_id=1, 1146 | max_power=-1.0, 1147 | efficiency=1.0): 1148 | """Init function. 1149 | 1150 | Args: 1151 | name: String name of the object. 1152 | nameplate_unit_cost: (float) Cost to build a unit of 1153 | transmission capacity. ($ / Megawatt of capacity) 1154 | source_grid_region_id: An int specifying which grid_region 1155 | power gets power added. 1156 | sink_grid_region_id: An int specifying which grid_region 1157 | power gets power subtracted. 1158 | max_power: (float) Optional Maximum power which can be transmitted. 1159 | (Megawatt). Set < 0 if there is no limit. 1160 | efficiency: (float) ratio of how much power gets moved one 1161 | grid_region to the other grid_region. Acceptable values are 1162 | 0. < efficiency < 1. 1163 | """ 1164 | 1165 | super(_GridTransmission, self).__init__( 1166 | name, 1167 | nameplate_unit_cost=nameplate_unit_cost, 1168 | variable_unit_cost=0, 1169 | grid_region_id=source_grid_region_id, 1170 | max_power=max_power, 1171 | max_energy=-1, 1172 | co2_per_electrical_energy=0, 1173 | power_coefficient=efficiency 1174 | ) 1175 | 1176 | self.sink_grid_region_id = sink_grid_region_id 1177 | 1178 | self.solver = _GridSourceDispatchableSolver(self) 1179 | 1180 | def configure_lp_variables_and_constraints(self, lp): 1181 | """Declare lp variables, and set constraints. 1182 | 1183 | Args: 1184 | lp: LinearProgramContainer, contains lp solver and constraints. 1185 | """ 1186 | 1187 | super(_GridTransmission, self).configure_lp_variables_and_constraints(lp) 1188 | 1189 | # Handle Constraints. 1190 | for t, var in enumerate(self.timeslice_variables): 1191 | 1192 | sink_id = self.sink_grid_region_id 1193 | source_id = self.grid_region_id 1194 | 1195 | # Whatever the super-class is sourcing in source_grid_region_id, 1196 | # sink it from sink_grid_region_id. 1197 | lp.conserve_power_constraint[sink_id][t].set_coefficient(var, -1.0) 1198 | if self.is_rps_source: 1199 | lp.rps_source_constraints[sink_id][t].set_coefficient(var, -1.0) 1200 | 1201 | def post_process(self, lp): 1202 | """Update lp post_processing result variables. 1203 | 1204 | This is done so that sanity data checks can be done on RPS before 1205 | returning results. 1206 | 1207 | Args: 1208 | lp: The LinearProgramContainer where the post processing variables reside. 1209 | """ 1210 | # Normal source post_process 1211 | super(_GridTransmission, self).post_process(lp) 1212 | 1213 | # Sink post_process 1214 | sink_id = self.sink_grid_region_id 1215 | if lp.rps_percent > 0.0 and self.is_rps_source: 1216 | lp.rps_total[sink_id] -= self.get_solution_values() 1217 | else: 1218 | lp.non_rps_total[sink_id] -= self.get_solution_values() 1219 | 1220 | 1221 | class GridTransmission(object): 1222 | """Transmits power bidirectionally between two grid_regions. 1223 | 1224 | At interface level, transmitting from region-m to region-n is 1225 | identical to transmitting from region-n to region-m. 1226 | 1227 | Attributes: 1228 | name: (str) name of the object. 1229 | nameplate_unit_cost: (float) Cost to build a unit of 1230 | transmission capacity. ($ / Megawatt of capacity) 1231 | grid_region_id_a: An int specifying one grid_region transmission 1232 | terminus 1233 | grid_region_id_b: An int specifying a different grid_region 1234 | transmission terminus 1235 | max_power: (float) Optional Maximum power which can be transmitted. 1236 | (Megawatt). Set < 0 if there is no limit. 1237 | efficiency: (float) ratio of how much power gets moved one 1238 | grid_region to the other grid_region. Acceptable values are 1239 | 0. < efficiency < 1. 1240 | a_to_b: _GridTransmission object which moves dirty power from 1241 | grid_region_a to grid_region_b 1242 | b_to_a: _GridTransmission object which moves dirty power from 1243 | grid_region_b to grid_region_a 1244 | rec_a_to_b: _GridTransmission object which moves clean power 1245 | from grid_region_a to grid_region_b 1246 | rec_b_to_a: _GridTransmission object which moves clean power 1247 | from grid_region_b to grid_region_a 1248 | """ 1249 | 1250 | def __init__( 1251 | self, 1252 | name, 1253 | nameplate_unit_cost, 1254 | grid_region_id_a, 1255 | grid_region_id_b, 1256 | efficiency=1.0, 1257 | max_power=-1.0, 1258 | ): 1259 | 1260 | self.name = name 1261 | self.nameplate_unit_cost = nameplate_unit_cost 1262 | self.grid_region_id_a = grid_region_id_a 1263 | self.grid_region_id_b = grid_region_id_b 1264 | self.efficiency = efficiency 1265 | self.max_power = max_power 1266 | 1267 | self.a_to_b = None 1268 | self.b_to_a = None 1269 | self.rec_a_to_b = None 1270 | self.rec_b_to_a = None 1271 | 1272 | def configure_lp_variables_and_constraints(self, lp): 1273 | """Declare lp variables, and set constraints. 1274 | 1275 | Args: 1276 | lp: LinearProgramContainer, contains lp solver and constraints. 1277 | """ 1278 | 1279 | self.a_to_b = _GridTransmission(self.name + ' a_to_b', 1280 | 0, 1281 | self.grid_region_id_b, 1282 | self.grid_region_id_a, 1283 | self.max_power, 1284 | self.efficiency) 1285 | 1286 | self.b_to_a = _GridTransmission(self.name + ' b_to_a', 1287 | 0, 1288 | self.grid_region_id_a, 1289 | self.grid_region_id_b, 1290 | self.max_power, 1291 | self.efficiency) 1292 | 1293 | self.rec_a_to_b = _GridTransmission(self.name + ' rec a_to_b', 1294 | 0, 1295 | self.grid_region_id_b, 1296 | self.grid_region_id_a, 1297 | self.max_power, 1298 | self.efficiency, 1299 | is_rps=True) 1300 | 1301 | self.rec_b_to_a = _GridTransmission(self.name + ' rec b_to_a', 1302 | 0, 1303 | self.grid_region_id_a, 1304 | self.grid_region_id_b, 1305 | self.max_power, 1306 | self.efficiency, 1307 | is_rps=True) 1308 | 1309 | self.a_to_b.configure_lp_variables_and_constraints(lp) 1310 | self.b_to_a.configure_lp_variables_and_constraints(lp) 1311 | self.rec_a_to_b.configure_lp_variables_and_constraints(lp) 1312 | self.rec_b_to_a.configure_lp_variables_and_constraints(lp) 1313 | 1314 | # Make sure nameplate >= sum(a_to_b) and nameplate >= sum(b_to_a) 1315 | 1316 | self.nameplate_variable = lp.declare_nameplate_variable( 1317 | self.name, '%d_%d' % (self.grid_region_id_a, self.grid_region_id_b)) 1318 | 1319 | lp.minimize_costs_objective.set_coefficient(self.nameplate_variable, 1320 | self.nameplate_unit_cost) 1321 | 1322 | for t in lp.time_index_iterable: 1323 | 1324 | # nameplate >= a_to_b[t] + rec_a_to_b[t] - b_to_a[t] - rec_b_to_a[t] 1325 | a_to_b_constraint = lp.constraint(0.0, lp.solver.infinity()) 1326 | a_to_b_constraint.set_coefficient(self.nameplate_variable, 1.0) 1327 | a_to_b_constraint.set_coefficient( 1328 | self.a_to_b.timeslice_variables[t], -1.0) 1329 | a_to_b_constraint.set_coefficient( 1330 | self.rec_a_to_b.timeslice_variables[t], -1.0) 1331 | a_to_b_constraint.set_coefficient( 1332 | self.b_to_a.timeslice_variables[t], 1.0) 1333 | a_to_b_constraint.set_coefficient( 1334 | self.rec_b_to_a.timeslice_variables[t], 1.0) 1335 | 1336 | # nameplate >= b_to_a[t] + rec_b_to_a[t] - a_to_b[t] - rec_a_to_b[t] 1337 | b_to_a_constraint = lp.constraint(0.0, lp.solver.infinity()) 1338 | b_to_a_constraint.set_coefficient(self.nameplate_variable, 1.0) 1339 | b_to_a_constraint.set_coefficient( 1340 | self.b_to_a.timeslice_variables[t], -1.0) 1341 | b_to_a_constraint.set_coefficient( 1342 | self.rec_b_to_a.timeslice_variables[t], -1.0) 1343 | b_to_a_constraint.set_coefficient( 1344 | self.a_to_b.timeslice_variables[t], 1.0) 1345 | b_to_a_constraint.set_coefficient( 1346 | self.rec_a_to_b.timeslice_variables[t], 1.0) 1347 | 1348 | def post_process(self, lp): 1349 | """Update lp post_processing result variables. 1350 | 1351 | This is done so that sanity data checks can be done on RPS before 1352 | returning results. 1353 | 1354 | Args: 1355 | lp: The LinearProgramContainer where the post processing variables reside. 1356 | """ 1357 | 1358 | self.a_to_b.post_process(lp) 1359 | self.b_to_a.post_process(lp) 1360 | self.rec_a_to_b.post_process(lp) 1361 | self.rec_b_to_a.post_process(lp) 1362 | 1363 | def get_nameplate_solution_value(self): 1364 | """Gets the linear program solver results for nameplate. 1365 | 1366 | Must be called after lp.solve() to ensure solver has properly 1367 | converged and has generated results. 1368 | 1369 | Raises: 1370 | RuntimeError: If called before LinearProgramContainer.solve(). 1371 | 1372 | Returns: 1373 | Float value representing solved nameplate value. 1374 | 1375 | """ 1376 | nameplate_variable = self.nameplate_variable 1377 | 1378 | if nameplate_variable is None: 1379 | raise RuntimeError('Get_nameplate_solution_value called before solve().') 1380 | 1381 | return nameplate_variable.solution_value() 1382 | 1383 | 1384 | class LinearProgramContainer(object): 1385 | """Instantiates and interfaces to LP Solver. 1386 | 1387 | Example Usage: 1388 | Initialize: lp = LinearProgramContainer() 1389 | Add objects: 1390 | lp.add_demands() 1391 | lp.add_sources() 1392 | lp.add_transmissions() 1393 | lp.solve() 1394 | 1395 | Attributes: 1396 | carbon_tax: The amount to tax 1 unit of co2 emissions. 1397 | cost_of_money: The amount to multiply variable costs by to 1398 | make yearly costs and fixed costs comparable. 1399 | profiles: time-series profiles indexed by name which map to 1400 | GridDemands and GridNonDispatchableSources. 1401 | number_of_timeslices: int representing one timeslice per profile index. 1402 | time_index_iterable: A simple int range from 0 - number_of_timeslices. 1403 | 1404 | Constraints: 1405 | conserve_power_constraint: Dict keyed by grid_region_id. Value 1406 | is a list of LP Constraints which ensures that power > demand 1407 | at all times in all grid_regions. 1408 | 1409 | minimize_costs_objective: The LP Objective which is to minimize costs. 1410 | 1411 | rps_source_constraints: Dict keyed by grid_region_id. Value is a 1412 | list of LP Constraints which ensures that 1413 | rps_credit[grid_region, t] <= sum(rps_sources[grid_region, t]) 1414 | 1415 | rps_demand_constraints: Dict keyed by grid_region_id. Value is 1416 | a list of LP Constraints which ensures that 1417 | rps_credit[grid_region, t] <= demand[grid_region, t] 1418 | 1419 | RPS Variables: 1420 | rps_credit_variables: Dict object keyed by grid_region_id. Value is a 1421 | list of rps_credit[grid_region, t] variables for calculating rps. 1422 | 1423 | Post Processing Variables. Computed after LP converges: 1424 | rps_total: Dict object keyed by grid_region_id. Value is sum 1425 | (GridSource_power[grid_region, t]) of all rps sources. 1426 | 1427 | non_rps_total: Dict object keyed by grid_region_id. Value is sum 1428 | (GridSource_power[grid_region, t]) of all non_rps sources. 1429 | 1430 | adjusted_demand: Dict object keyed by grid_region_id. Value is 1431 | Demand[grid_region, t] 1432 | 1433 | rps_credit_values: Dict object keyed by grid_region_id. Value is 1434 | rps_credit.value[grid_region, t] 1435 | 1436 | Grid Elements: 1437 | demands: A list of GridDemand(s). 1438 | sources: A list of GridSource(s). 1439 | storage: A list of GridStorage(s). 1440 | transmission: A list of GridTransmission(s). 1441 | 1442 | solver: The wrapped pywraplp.Solver. 1443 | solver_precision: A float representing estimated precision of the solver. 1444 | """ 1445 | 1446 | def __init__(self, profiles): 1447 | """Initializes LP Container. 1448 | 1449 | Args: 1450 | profiles: Time-series pandas dataframe profiles indexed by name 1451 | which map to GridDemands and GridNonDispatchableSources. 1452 | 1453 | Raises: 1454 | ValueError: If any value in profiles is < 0 or Nan / None. 1455 | """ 1456 | 1457 | self.carbon_tax = 0.0 1458 | self.cost_of_money = 1.0 1459 | self.rps_percent = 0.0 1460 | 1461 | self.profiles = profiles 1462 | 1463 | # Constraints 1464 | self.conserve_power_constraint = {} 1465 | self.minimize_costs_objective = None 1466 | 1467 | # RPS Constraints 1468 | self.rps_source_constraints = {} 1469 | self.rps_demand_constraints = {} 1470 | 1471 | # RPS Variables 1472 | self.rps_credit_variables = {} 1473 | 1474 | # Post Processing Variables 1475 | self.rps_total = {} 1476 | self.non_rps_total = {} 1477 | self.adjusted_demand = {} 1478 | self.total_demand = 0 1479 | self.rps_demand = 0 1480 | 1481 | self.rps_credit_values = {} 1482 | 1483 | self.demands = [] 1484 | self.sources = [] 1485 | self.storage = [] 1486 | self.transmission = [] 1487 | 1488 | self.solver = None 1489 | self.solver_precision = 1e-3 1490 | 1491 | # Validate profiles 1492 | if profiles is None: 1493 | raise ValueError('No profiles specified.') 1494 | 1495 | if profiles.empty: 1496 | raise ValueError('No Data in Profiles.') 1497 | 1498 | if profiles.isnull().values.any(): 1499 | raise ValueError('Profiles may not be Null or None') 1500 | 1501 | profiles_lt_0 = profiles.values < 0 1502 | if profiles_lt_0.any(): 1503 | raise ValueError('Profiles must not be < 0.') 1504 | 1505 | self.number_of_timeslices = len(profiles) 1506 | self.time_index_iterable = range(self.number_of_timeslices) 1507 | 1508 | def add_demands(self, *demands): 1509 | 1510 | """Add all GridDemands in Args to self.demands.""" 1511 | for d in demands: 1512 | self.demands.append(d) 1513 | 1514 | def add_dispatchable_sources(self, *sources): 1515 | """Verify source has no profile associated with it and add to self.sources. 1516 | 1517 | Args: 1518 | *sources: arbitrary number of GridSources. 1519 | 1520 | Raises: 1521 | KeyError: if Source has a profile associated with it which would 1522 | indicate the source was non-dispatchable instead of 1523 | dispatchable. 1524 | """ 1525 | 1526 | for source in sources: 1527 | if source.name in self.profiles: 1528 | raise KeyError( 1529 | 'Dispatchable Source %s has a profile associated with it' %( 1530 | source.name 1531 | ) 1532 | ) 1533 | 1534 | source.solver = _GridSourceDispatchableSolver(source) 1535 | self.sources.append(source) 1536 | 1537 | def add_nondispatchable_sources(self, *sources): 1538 | """Verify source has a profile associated with it and add to self.sources. 1539 | 1540 | Args: 1541 | *sources: arbitrary number of GridSources. 1542 | 1543 | Raises: 1544 | KeyError: if Source has no profile associated with it which would 1545 | indicate the source was dispatchable instead of 1546 | non-dispatchable. 1547 | """ 1548 | 1549 | for source in sources: 1550 | if source.name not in self.profiles: 1551 | known_sources = ','.join(sorted(self.profiles.columns)) 1552 | known_source_string = 'Known sources are (%s).' % known_sources 1553 | raise KeyError( 1554 | 'Nondispatchable Source %s has no profile. %s' % ( 1555 | source.name, 1556 | known_source_string 1557 | ) 1558 | ) 1559 | 1560 | source.solver = _GridSourceNonDispatchableSolver( 1561 | source, 1562 | self.profiles[source.name]) 1563 | 1564 | self.sources.append(source) 1565 | 1566 | def add_storage(self, *storage): 1567 | """Add storage to lp.""" 1568 | 1569 | self.storage.extend(storage) 1570 | 1571 | def add_transmissions(self, *transmission): 1572 | """Add transmission to lp.""" 1573 | 1574 | self.transmission.extend(transmission) 1575 | 1576 | def constraint(self, lower, upper, name=None, debug=False): 1577 | """Build a new Constraint which with valid range between lower and upper.""" 1578 | return Constraint(self, lower, upper, name, debug) 1579 | 1580 | def _initialize_solver(self): 1581 | """Initializes solver, declares objective and set constraints. 1582 | 1583 | Solver is pywraplp.solver. 1584 | Objective is to minimize costs subject to constraints. 1585 | 1586 | One constraint declared here is to ensure that 1587 | power[grid_region][t] > demand[grid_region][t] for all t and 1588 | grid_regions. 1589 | 1590 | Also configures GridElements. 1591 | 1592 | """ 1593 | self.solver = pywraplp.Solver('SolveEnergy', 1594 | pywraplp.Solver.CLP_LINEAR_PROGRAMMING) 1595 | 1596 | self.minimize_costs_objective = Objective(self, minimize=True) 1597 | 1598 | # Initialize GridDemands and GridSources 1599 | demand_sum = 0.0 1600 | for d in self.demands: 1601 | try: 1602 | profiles = self.profiles[d.name] 1603 | self.adjusted_demand[d.grid_region_id] = np.array(profiles.values) 1604 | except KeyError: 1605 | profile_names = str(self.profiles.keys()) 1606 | error_string = 'GridDemand %s. No profile found! Known profiles:(%s)' %( 1607 | d.name, 1608 | profile_names 1609 | ) 1610 | 1611 | raise KeyError(error_string) 1612 | self.conserve_power_constraint[d.grid_region_id] = [ 1613 | self.constraint(p, 1614 | self.solver.infinity(), 1615 | 'Conserve Power gid:%d t:%d' %( 1616 | d.grid_region_id, t)) 1617 | for t, p in enumerate(profiles) 1618 | ] 1619 | 1620 | demand_sum += sum(profiles) 1621 | 1622 | # Handle RPS which is tricky. It requires special credit 1623 | # variables[grid_region][time] and 3 constraints. 1624 | # 1625 | # Constraint #1: 1626 | # The overall goal is to have RPS exceed rps_percent of total 1627 | # demand. Given that: 1628 | # total_rps_credit := sum(rps_credit[g][t]) 1629 | # total_demand := sum(demand[g][t]) 1630 | # 1631 | # The constraint named total_rps_credit_gt_rps_percent_constraint 1632 | # is: 1633 | # total_rps_credit >= (self.rps_percent / 100) * total_demand 1634 | # 1635 | # Constraint #2: 1636 | # rps_credit[g][t] cannot exceed sum of rps_sources - sum of 1637 | # rps_sinks at each g,t. An example of rps_sink is the 'REC_STORAGE' 1638 | # part of GridRecStorage which stores rps energy off the grid only 1639 | # to put it back on the grid later as a rps_source. This is 1640 | # reflected in the constraint named 1641 | # rps_source_constraints[g][t]: 1642 | # rps_credit[g][t] <= sum(rps_sources[g][t]) - sum(rps_sinks[g][t]) 1643 | # 1644 | # Constraint #3 1645 | # rps_credit[g][t] cannot exceed what can be used at each g,t. if 1646 | # rps_sources generate a Gigawatt at g,t = 0,0 and only 1MW can be 1647 | # used at g,t then we don't want to credit the unused 999 MW. 1648 | # 1649 | # The constraint named rps_demand_constraints is: 1650 | # rps_credit[g][t] <= demand[g][t] 1651 | # 1652 | 1653 | self.total_demand = demand_sum 1654 | self.rps_demand = demand_sum * self.rps_percent / 100. 1655 | solver = self.solver 1656 | total_rps_credit_gt_rps_percent_constraint = self.constraint( 1657 | self.rps_demand, 1658 | solver.infinity() 1659 | ) 1660 | 1661 | for d in self.demands: 1662 | profiles = self.profiles[d.name] 1663 | 1664 | if self.rps_percent > 0.0: 1665 | rps_credit_variables = self.declare_timeslice_variables( 1666 | '__rps_credit__', 1667 | d.grid_region_id 1668 | ) 1669 | else: 1670 | rps_credit_variables = [solver.NumVar(0.0, 0.0, 1671 | '__bogus rps_credit__ %d %d' %( 1672 | d.grid_region_id, t)) 1673 | for t in self.time_index_iterable] 1674 | 1675 | rps_demand_constraints = [] 1676 | rps_source_constraints = [self.constraint(0.0, solver.infinity()) 1677 | for t in self.time_index_iterable] 1678 | 1679 | self.rps_source_constraints[d.grid_region_id] = rps_source_constraints 1680 | 1681 | self.rps_credit_variables[d.grid_region_id] = rps_credit_variables 1682 | 1683 | for t in self.time_index_iterable: 1684 | 1685 | # Sum(rps_credit[grid_region, t]) >= rps_percent * total demand. 1686 | total_rps_credit_gt_rps_percent_constraint.set_coefficient( 1687 | rps_credit_variables[t], 1.0) 1688 | 1689 | # Rps_credit[grid_region, t] <= demand[grid_region, t]. 1690 | rps_credit_less_than_demand = self.constraint(-solver.infinity(), 1691 | profiles[t]) 1692 | rps_credit_less_than_demand.set_coefficient(rps_credit_variables[t], 1693 | 1.0) 1694 | rps_demand_constraints.append(rps_credit_less_than_demand) 1695 | 1696 | # Rps_credit[grid_region, t] <= (sum(rps_sources[grid_region, t]) 1697 | # Constraint also gets adjusted by _GridSource(Non)DispatchableSolver. 1698 | # configure_lp_variables_and_constraints 1699 | rps_source_constraints[t].set_coefficient(rps_credit_variables[t], 1700 | -1.0) 1701 | 1702 | self.rps_demand_constraints[d.grid_region_id] = rps_demand_constraints 1703 | 1704 | # Configure sources and storage. 1705 | for s in self.sources + self.storage + self.transmission: 1706 | s.configure_lp_variables_and_constraints(self) 1707 | 1708 | def solve(self): 1709 | """Initializes and runs linear program. 1710 | 1711 | This is the main routine to call after __init__. 1712 | 1713 | Returns: 1714 | True if linear program gave an optimal result. False otherwise. 1715 | """ 1716 | self._initialize_solver() 1717 | status = self.solver.Solve() 1718 | converged = status == self.solver.OPTIMAL 1719 | 1720 | if converged: 1721 | self._post_process() 1722 | 1723 | return converged 1724 | 1725 | def _post_process(self): 1726 | """Generates data used for calculating consumed rps/non-rps values. 1727 | 1728 | Also double-checks results to make sure they match constraints. 1729 | 1730 | Raises: 1731 | RuntimeError: If double-checked results do not match constraints. 1732 | """ 1733 | 1734 | # Initialize post_processing totals. 1735 | for d in self.demands: 1736 | 1737 | # Total amount of rps_sources[g][t] power. 1738 | self.rps_total[d.grid_region_id] = np.zeros(self.number_of_timeslices) 1739 | # Total amount of non-rps_sources[g][t] power. 1740 | self.non_rps_total[d.grid_region_id] = np.zeros(self.number_of_timeslices) 1741 | 1742 | for s in self.sources + self.storage + self.transmission: 1743 | s.post_process(self) 1744 | 1745 | # Sanity error check results against constraints. If any of these 1746 | # get raised, it indicates a bug in the code. 1747 | solver_precision = self.solver_precision 1748 | sum_rps_credits = 0.0 1749 | for g_id in [d.grid_region_id for d in self.demands]: 1750 | 1751 | power_deficit = (self.adjusted_demand[g_id] - 1752 | (self.rps_total[g_id] + self.non_rps_total[g_id])) 1753 | 1754 | lights_kept_on = (power_deficit < solver_precision).all() 1755 | 1756 | rps_credits = np.array( 1757 | [rcv.solution_value() for rcv in self.rps_credit_variables[g_id]]) 1758 | sum_rps_credits += sum(rps_credits) 1759 | self.rps_credit_values[g_id] = rps_credits 1760 | 1761 | rps_credit_gt_demand = ( 1762 | rps_credits > self.adjusted_demand[g_id] + solver_precision).all() 1763 | 1764 | rps_credit_gt_rps_sources = ( 1765 | rps_credits > self.rps_total[g_id] + solver_precision).all() 1766 | 1767 | storage_exceeds_demand = ( 1768 | self.adjusted_demand[g_id] < -solver_precision).all() 1769 | 1770 | if not lights_kept_on: 1771 | raise DemandNotSatisfiedError( 1772 | 'Demand not satisfied by %f for region %d' % (max(power_deficit), 1773 | g_id)) 1774 | 1775 | if rps_credit_gt_demand: 1776 | raise RpsExceedsDemandError( 1777 | 'RPS Credits Exceed Demand for region %d' %g_id) 1778 | 1779 | if rps_credit_gt_rps_sources: 1780 | raise RpsCreditExceedsSourcesError( 1781 | 'RPS Credits Exceed RPS Sources for region %d' %g_id) 1782 | 1783 | if storage_exceeds_demand: 1784 | raise StorageExceedsDemandError( 1785 | 'Storage Exceeds Demand for region %d' %g_id) 1786 | 1787 | # Scale solver_precision by number of timeslices to get precision 1788 | # for a summed comparison. 1789 | sum_solver_precision = solver_precision * self.number_of_timeslices 1790 | if sum_solver_precision + sum_rps_credits < self.rps_demand: 1791 | raise RpsPercentNotMetError( 1792 | 'Sum RPS credits (%f) < demand * (%f rps_percent) (%f)' %( 1793 | sum_rps_credits, 1794 | float(self.rps_percent), 1795 | self.rps_demand 1796 | ) 1797 | ) 1798 | 1799 | def declare_timeslice_variables(self, name, grid_region_id): 1800 | """Declares timeslice variables for a grid_region. 1801 | 1802 | Args: 1803 | name: String to be included in the generated variable name. 1804 | grid_region_id: Int which identifies which grid these variables affect. 1805 | 1806 | Do Not call this function with the same (name, grid_region_id) 1807 | pair more than once. There may not be identically named variables 1808 | in the same grid_region. 1809 | 1810 | Returns: 1811 | Array of lp variables, each which range from 0 to infinity. 1812 | Array is mapped so that variable for time-slice x is at index x. 1813 | e.g. variable for first time-slice is variable[0]. variable for 1814 | last time-slice is variable[-1] 1815 | 1816 | """ 1817 | 1818 | solver = self.solver 1819 | 1820 | variables = [] 1821 | for t in self.time_index_iterable: 1822 | var_name = '__'.join([name, 1823 | 'grid_region_id', 1824 | str(grid_region_id), 1825 | 'at_t', 1826 | str(t)]) 1827 | 1828 | variables.append(solver.NumVar(0.0, 1829 | solver.infinity(), 1830 | var_name)) 1831 | return variables 1832 | 1833 | def declare_nameplate_variable(self, name, grid_region_id): 1834 | """Declares a nameplate variable for a grid_region. 1835 | 1836 | Args: 1837 | name: String to be included in the generated variable name. 1838 | grid_region_id: Stringifyable object which identifies which grid 1839 | these variables affect. 1840 | 1841 | Do Not call this function with the same (name, grid_region_id) 1842 | pair more than once. There may not be identically named variables 1843 | in the same grid_region. 1844 | 1845 | Returns: 1846 | A lp variable which values range from 0 to infinity. 1847 | 1848 | """ 1849 | 1850 | nameplate_name = '__'.join([name, 1851 | 'grid_region_id', str(grid_region_id), 1852 | 'peak']) 1853 | 1854 | solver = self.solver 1855 | return solver.NumVar(0.0, 1856 | solver.infinity(), 1857 | nameplate_name) 1858 | 1859 | 1860 | def extrapolate_cost(cost, discount_rate, time_span_1, time_span_2): 1861 | """Extrapolate cost from one time span to another. 1862 | 1863 | Args: 1864 | cost: cost incurred during time_span_1 (in units of currency) 1865 | discount_rate: rate that money decays, per year (as decimal, e.g., .06) 1866 | time_span_1: time span when cost incurred (in units of years) 1867 | time_span_2: time span to extrapolate cost (in units of years) 1868 | 1869 | Returns: 1870 | Cost extrapolated to time_span_2, units of currency. 1871 | 1872 | Model parameters are costs over time spans. For example, the demand 1873 | may be a time series that lasts 1 year. The variable cost to fulfill 1874 | that demand would then be for 1 year of operation. However, the 1875 | GridModel is supposed to compute the total cost over a longer time 1876 | span (e.g., 30 years). 1877 | 1878 | If there were no time value of money, the extrapolated cost would be 1879 | the ratio of time_span_2 to time_span_1 (e.g., 30 in the 1880 | example). However, payments in the future are less costly than 1881 | payments in the present. We extrapolate the cost by first finding 1882 | the equivalent continuous stream of payments over time_span_1 that 1883 | is equivalent to the cost, then assume that stream of payments 1884 | occurs over time_span_2, instead. 1885 | 1886 | """ 1887 | growth_rate = 1.0 + discount_rate 1888 | value_decay_1 = pow(growth_rate, -time_span_2) 1889 | value_decay_2 = pow(growth_rate, -time_span_1) 1890 | 1891 | try: 1892 | return cost * (1.0 - value_decay_1) / (1.0-value_decay_2) 1893 | except ZeroDivisionError: 1894 | return cost 1895 | --------------------------------------------------------------------------------