├── 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 |
--------------------------------------------------------------------------------