├── .github
└── workflows
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── docs
├── docs
│ ├── assets
│ │ ├── battery.md
│ │ ├── chp.md
│ │ ├── evs.md
│ │ ├── heat-pump.md
│ │ └── renewable-generator.md
│ ├── changelog.md
│ ├── examples
│ │ └── renewable-battery-export.py
│ ├── getting-started.md
│ ├── how-to
│ │ ├── battery-degradation.md
│ │ ├── complex-terms.md
│ │ ├── custom-constraints.md
│ │ ├── custom-interval-data.md
│ │ ├── custom-objectives.md
│ │ ├── dispatch-forecast.md
│ │ ├── dispatch-site.md
│ │ ├── network-charges.md
│ │ └── price-carbon.md
│ ├── index.md
│ ├── performance.md
│ └── static
│ │ ├── favicon.ico
│ │ └── logo.png
├── generate-plots.py
├── mkdocs.yml
└── requirements.txt
├── energypylinear
├── __init__.py
├── accounting
│ ├── __init__.py
│ └── accounting.py
├── assets
│ ├── __init__.py
│ ├── asset.py
│ ├── battery.py
│ ├── boiler.py
│ ├── chp.py
│ ├── evs.py
│ ├── heat_pump.py
│ ├── renewable_generator.py
│ ├── site.py
│ ├── spill.py
│ └── valve.py
├── constraints.py
├── data_generation.py
├── debug.py
├── defaults.py
├── flags.py
├── freq.py
├── interval_data.py
├── logger.py
├── objectives.py
├── optimizer.py
├── plot.py
├── results
│ ├── __init__.py
│ ├── checks.py
│ ├── extract.py
│ ├── schema.py
│ └── warnings.py
└── utils.py
├── poc
├── constraint-fun.py
├── multi-site.py
├── participant-optimization.py
└── schematic.py
├── poetry.lock
├── poetry.toml
├── pyproject.toml
├── static
└── coverage.svg
└── tests
├── assert-test-coverage.py
├── assets
├── test_battery.py
├── test_chp.py
├── test_evs.py
├── test_heat_pump.py
├── test_renewable_generator.py
└── test_site.py
├── common.py
├── conftest.py
├── generate-test-docs.sh
├── test_accounting.py
├── test_complex_terms.py
├── test_constraints.py
├── test_custom_constraints.py
├── test_custom_objectives.py
├── test_debug.py
├── test_extra_interval_data.py
├── test_flags.py
├── test_freq.py
├── test_interval_vars.py
├── test_network_charges.py
├── test_optimizer.py
├── test_plot.py
├── test_repr.py
├── test_spill_warnings.py
└── test_utils.py
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: publish
3 | on:
4 | release:
5 | types: [published]
6 | jobs:
7 | publish:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-python@v5
12 | with:
13 | python-version: 3.11.5
14 | - run: make publish PYPI_TOKEN=${{ secrets.PYPI_TOKEN }}
15 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: test
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: ['*']
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 30
12 | strategy:
13 | matrix:
14 | python-version: [3.11.9, 3.12.8]
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: actions/setup-python@v5
18 | with:
19 | python-version: ${{ matrix.python-version }}
20 | - run: make test QUIET=
21 | check:
22 | runs-on: ubuntu-latest
23 | strategy:
24 | matrix:
25 | python-version: [3.11.0, 3.12.0]
26 | steps:
27 | - uses: actions/checkout@v4
28 | - uses: actions/setup-python@v5
29 | with:
30 | python-version: ${{ matrix.python-version }}
31 | - run: make check QUIET=
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | tests/phmdoctest/**/*
2 | logs
3 | docs/.cache
4 | .coverage*
5 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | See [docs/changelog](https://energypylinear.adgefficiency.com/latest/changelog).
2 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all clean
2 |
3 | all: test
4 |
5 | clean:
6 | rm -rf .pytest_cache .hypothesis .mypy_cache .ruff_cache __pycache__ .coverage logs .coverage*
7 |
8 |
9 | # ----- SETUP -----
10 | # installation of dependencies
11 |
12 | .PHONY: setup-pip-poetry setup-test setup-static setup-check setup-docs
13 | QUIET := -q
14 | PIP_CMD=pip
15 |
16 | setup-pip-poetry:
17 | $(PIP_CMD) install --upgrade pip $(QUIET)
18 | $(PIP_CMD) install poetry==1.7.0 $(QUIET)
19 |
20 | setup: setup-pip-poetry
21 | poetry install --with main $(QUIET)
22 |
23 | setup-test: setup-pip-poetry
24 | poetry install --with test $(QUIET)
25 |
26 | setup-static: setup-pip-poetry
27 | poetry install --with static $(QUIET)
28 |
29 | setup-check: setup-pip-poetry
30 | poetry install --with check $(QUIET)
31 |
32 | # manage docs dependencies separately because
33 | # we build docs on netlify
34 | # netlify only has Python 3.8
35 | # TODO could change this now we use mike
36 | # as we don't run a build on netlify anymore
37 | setup-docs:
38 | $(PIP_CMD) install -r ./docs/requirements.txt $(QUIET)
39 |
40 |
41 | # ----- TEST -----
42 | # documentation tests and unit tests
43 |
44 | .PHONY: test generate-test-docs test-docs
45 | PARALLEL = auto
46 | TEST_ARGS =
47 | export
48 |
49 | test: setup-test test-docs
50 | pytest tests --cov=energypylinear --cov-report=html --cov-report=term-missing -n $(PARALLEL) --color=yes --durations=5 --verbose --ignore tests/phmdoctest $(TEST_ARGS)
51 | # -coverage combine
52 | # -coverage html
53 | -coverage report
54 | python tests/assert-test-coverage.py $(TEST_ARGS)
55 |
56 | generate-test-docs: setup-test
57 | bash ./tests/generate-test-docs.sh
58 |
59 | test-docs: setup-test generate-test-docs
60 | pytest tests/phmdoctest -n 1 --dist loadfile --color=yes --verbose $(TEST_ARGS)
61 |
62 |
63 | # ----- CHECK -----
64 | # linting and static typing
65 |
66 | .PHONY: check lint static
67 |
68 | check: lint static
69 |
70 | MYPY_ARGS=--pretty
71 | static: setup-static
72 | rm -rf ./tests/phmdoctest
73 | mypy --version
74 | mypy $(MYPY_ARGS) ./energypylinear
75 | mypy $(MYPY_ARGS) ./tests --explicit-package-bases
76 |
77 | lint: setup-check
78 | rm -rf ./tests/phmdoctest
79 | flake8 --extend-ignore E501,DAR --exclude=__init__.py,poc
80 | ruff check . --ignore E501 --extend-exclude=__init__.py,poc
81 | isort --check **/*.py --profile black
82 | ruff format --check **/*.py
83 | poetry check
84 |
85 | CHECK_DOCSTRINGS=./energypylinear/objectives.py ./energypylinear/assets/battery.py ./energypylinear/assets/renewable_generator.py
86 |
87 | # currently only run manually
88 | lint-docstrings:
89 | flake8 --extend-ignore E501 --exclude=__init__.py,poc --exit-zero $(CHECK_DOCSTRINGS)
90 | pydocstyle $(CHECK_DOCSTRINGS)
91 | # pylint $(CHECK_DOCSTRINGS)
92 |
93 |
94 | # ----- FORMATTING -----
95 | # formatting code
96 |
97 | .PHONY: format
98 | format: setup-check
99 | isort **/*.py --profile black
100 | ruff format **/*.py
101 |
102 |
103 | # ----- PUBLISH ------
104 | # updating package on pypi
105 |
106 | .PHONY: publish
107 | -include .env.secret
108 |
109 | publish: setup
110 | poetry build
111 | @poetry config pypi-token.pypi $(PYPI_TOKEN)
112 | poetry publish
113 | # TODO publish docs automatically
114 |
115 |
116 | # ----- DOCS ------
117 | # mkdocs documentation
118 |
119 | .PHONY: docs mike-deploy
120 |
121 | generate-docs-images: setup
122 | python ./docs/generate-plots.py
123 |
124 | docs: setup-docs
125 | # `mike serve` will show docs for the different versions
126 | # `mkdocs serve` will show docs for the current version in markdown
127 | # `mkdocs serve` will usually be more useful during development
128 | cd docs; mkdocs serve -a localhost:8004; cd ..
129 |
130 | # TODO currently run manually - should be automated with publishing
131 | # this deploys the current docs to the docs branch
132 | # -u = update aliases of this $(VERSION) to latest
133 | # -b = branch - aligns with the branch name we build docs off
134 | # -r = Github remote
135 | # -p = push
136 | # TODO - get VERSION from pyproject.toml
137 | # TODO - this is not used in CI anywhere yet
138 | mike-deploy: setup-docs generate-docs-images
139 | cd docs; mike deploy $(VERSION) latest -u -b mike-pages -r origin -p
140 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # energy-py-linear
2 |
3 |
[](https://mypy-lang.org/)
4 |
5 | ---
6 |
7 | Documentation: [energypylinear.adgefficiency.com](https://energypylinear.adgefficiency.com/latest)
8 |
9 | ---
10 |
11 | A Python library for optimizing energy assets with mixed-integer linear programming:
12 |
13 | - electric batteries,
14 | - combined heat & power (CHP) generators,
15 | - electric vehicle smart charging,
16 | - heat pumps,
17 | - renewable (wind & solar) generators.
18 |
19 | Assets can be optimized to either maximize profit or minimize carbon emissions, or for user defined custom objective functions. Custom constraints can be used to further constrain asset behaviour.
20 |
21 | A site is a collection of assets that can be optimized together. Sites can use custom objectives and constraints.
22 |
23 | Energy balances are performed on electricity, high, and low temperature heat.
24 |
25 | ## Setup
26 |
27 | Requires Python 3.11 or 3.12:
28 |
29 | ```shell-session
30 | $ pip install energypylinear
31 | ```
32 |
33 | ## Quick Start
34 |
35 | ### Asset API
36 |
37 | The asset API allows optimizing a single asset at once:
38 |
39 | ```python
40 | import energypylinear as epl
41 |
42 | # 2.0 MW, 4.0 MWh battery
43 | asset = epl.Battery(
44 | power_mw=2,
45 | capacity_mwh=4,
46 | efficiency_pct=0.9,
47 | # different electricity prices for each interval
48 | # length of electricity_prices is the length of the simulation
49 | electricity_prices=[100.0, 50, 200, -100, 0, 200, 100, -100],
50 | # a constant value for each interval
51 | export_electricity_prices=40,
52 | )
53 |
54 | simulation = asset.optimize()
55 | ```
56 |
57 | ### Site API
58 |
59 | The site API allows optimizing multiple assets together:
60 |
61 | ```python
62 | import energypylinear as epl
63 |
64 | assets = [
65 | # 2.0 MW, 4.0 MWh battery
66 | epl.Battery(power_mw=2.0, capacity_mwh=4.0),
67 | # 30 MW open cycle generator
68 | epl.CHP(
69 | electric_power_max_mw=100, electric_power_min_mw=30, electric_efficiency_pct=0.4
70 | ),
71 | # 2 EV chargers & 4 charge events
72 | epl.EVs(
73 | chargers_power_mw=[100, 100],
74 | charge_events_capacity_mwh=[50, 100, 30, 40],
75 | charge_events=[
76 | [1, 0, 0, 0, 0],
77 | [0, 1, 1, 1, 0],
78 | [0, 0, 0, 1, 1],
79 | [0, 1, 0, 0, 0],
80 | ],
81 | ),
82 | # natural gas boiler to generate high temperature heat
83 | epl.Boiler(),
84 | # valve to generate low temperature heat from high temperature heat
85 | epl.Valve(),
86 | ]
87 |
88 | site = epl.Site(
89 | assets=assets,
90 | # length of energy prices is the length of the simulation
91 | electricity_prices=[100, 50, 200, -100, 0],
92 | # these should match the length of the export_electricity_prices
93 | # if they don't, they will be repeated or cut to match the length of electricity_prices
94 | high_temperature_load_mwh=[105, 110, 120, 110, 105],
95 | low_temperature_load_mwh=[105, 110, 120, 110, 105],
96 | )
97 |
98 | simulation = site.optimize()
99 | ```
100 |
101 | ## Documentation
102 |
103 | [See more asset types & use cases in the documentation](https://energypylinear.adgefficiency.com/latest).
104 |
105 | ## Test
106 |
107 | ```shell
108 | $ make test
109 | ```
110 |
--------------------------------------------------------------------------------
/docs/docs/assets/battery.md:
--------------------------------------------------------------------------------
1 | The `epl.Battery` asset is suitable for modelling an electric battery, such as a lithium-ion battery.
2 |
3 | ## Assumptions
4 |
5 | The battery charge rate is defined by `power_mw`, which defines both the maximum rate of charge and discharge. `discharge_power_mw` can be used to define a different rate of maximum discharge. Both the charge and discharge power are independent of the battery state of charge.
6 |
7 | The battery storage capacity is defined by `capacity_mwh`. This should be the capacity after taking into account any battery depth of discharge limits.
8 |
9 | An efficiency penalty is applied to the battery charge energy, based on the `efficiency_pct` parameter. No electricity is lost when discharging or during storage. The efficiency is independent of the battery state of charge.
10 |
11 | `initial_charge_mwh` and `final_charge_mwh` control the battery state of charge at the start and end of the simulation. These can cause infeasible simulations if the battery is unable to charge or discharge enough to meet these constraints.
12 |
13 | ## Use
14 |
15 | You can optimize a single battery with `epl.Battery`:
16 |
17 | ```python
18 | import energypylinear as epl
19 |
20 | asset = epl.Battery(
21 | power_mw=2,
22 | discharge_power_mw=2,
23 | capacity_mwh=4,
24 | efficiency_pct=0.9,
25 | electricity_prices=[100.0, 50, 200, -100, 0, 200, 100, -100],
26 | freq_mins=60,
27 | initial_charge_mwh=1,
28 | final_charge_mwh=3,
29 | name="battery",
30 | include_spill=True
31 | )
32 | simulation = asset.optimize()
33 |
34 | assert all(
35 | simulation.results.columns
36 | == [
37 | "site-import_power_mwh",
38 | "site-export_power_mwh",
39 | "site-electricity_prices",
40 | "site-export_electricity_prices",
41 | "site-electricity_carbon_intensities",
42 | "site-gas_prices",
43 | "site-electric_load_mwh",
44 | "site-high_temperature_load_mwh",
45 | "site-low_temperature_load_mwh",
46 | "site-low_temperature_generation_mwh",
47 | "spill-electric_generation_mwh",
48 | "spill-electric_load_mwh",
49 | "spill-high_temperature_generation_mwh",
50 | "spill-low_temperature_generation_mwh",
51 | "spill-high_temperature_load_mwh",
52 | "spill-low_temperature_load_mwh",
53 | "spill-gas_consumption_mwh",
54 | "battery-electric_charge_mwh",
55 | "battery-electric_charge_binary",
56 | "battery-electric_discharge_mwh",
57 | "battery-electric_discharge_binary",
58 | "battery-electric_loss_mwh",
59 | "battery-electric_initial_charge_mwh",
60 | "battery-electric_final_charge_mwh",
61 | "total-electric_generation_mwh",
62 | "total-electric_load_mwh",
63 | "total-high_temperature_generation_mwh",
64 | "total-low_temperature_generation_mwh",
65 | "total-high_temperature_load_mwh",
66 | "total-low_temperature_load_mwh",
67 | "total-gas_consumption_mwh",
68 | "total-electric_charge_mwh",
69 | "total-electric_discharge_mwh",
70 | "total-spills_mwh",
71 | "total-electric_loss_mwh",
72 | "site-electricity_balance_mwh",
73 | ],
74 | )
75 | ```
76 |
77 | ## Validation
78 |
79 | A natural response when you get access to something someone else built is to wonder - does this work correctly?
80 |
81 | This section will give you confidence in the implementation of the battery asset.
82 |
83 | ### Price Dispatch Behaviour
84 |
85 | Let's optimize a battery using a sequence of five prices.
86 |
87 | We expect that the battery will charge when prices are low, and will discharge when prices are high.
88 |
89 | In `energypylinear`, a positive site electricity balance is importing, and a negative site electricity balance is exporting.
90 |
91 | ```python
92 | import energypylinear as epl
93 |
94 | asset = epl.Battery(
95 | electricity_prices=[10, -50, 200, -50, 200],
96 | )
97 | simulation = asset.optimize(verbose=3)
98 | print(simulation.results[["site-electricity_prices", "site-electricity_balance_mwh"]])
99 | ```
100 |
101 | ```
102 | site-electricity_prices site-electricity_balance_mwh
103 | 0 10.0 0.444444
104 | 1 -50.0 2.000000
105 | 2 200.0 -2.000000
106 | 3 -50.0 2.000000
107 | 4 200.0 -2.000000
108 | ```
109 |
110 | As expected, the battery charges (with a site that is positive) when prices are low and discharges (with a negative site electricity balance) when prices are high.
111 |
112 | Now let's change the prices and see how the dispatch changes:
113 |
114 | ```python
115 | import energypylinear as epl
116 |
117 | asset = epl.Battery(
118 | electricity_prices=[200, -50, -50, 200, 220],
119 | )
120 | simulation = asset.optimize(verbose=3)
121 | print(simulation.results[["site-electricity_prices", "site-electricity_balance_mwh"]])
122 | ```
123 |
124 | ```
125 | site-electricity_prices site-electricity_balance_mwh
126 | 0 200.0 0.0
127 | 1 -50.0 2.0
128 | 2 -50.0 2.0
129 | 3 200.0 -1.6
130 | 4 220.0 -2.0
131 | ```
132 |
133 | As expected, the battery continues to charge during low electricity price intervals, and discharge when electricity prices are high.
134 |
135 | ### Battery Energy Balance
136 |
137 | Let's return to our original set of prices and check the energy balance of the battery:
138 |
139 | ```python
140 | import pandas as pd
141 | import energypylinear as epl
142 |
143 | pd.set_option("display.max_columns", 30)
144 | pd.set_option("display.width", 400)
145 |
146 | asset = epl.Battery(
147 | electricity_prices=[10, -50, 200, -50, 200],
148 | )
149 | simulation = asset.optimize(verbose=3)
150 |
151 | checks = epl.check_results(simulation.results, verbose=3)
152 | balance = checks["electricity-balance"]
153 | print(balance)
154 | ```
155 |
156 | ```
157 | input accumulation output balance import generation export load charge discharge loss spills soc
158 | 0 0.444444 -0.444444 0.0 True 0.444444 0.0 0.0 0 0.444444 0.0 0.044444 0.0 0.0
159 | 1 2.000000 -2.000000 0.0 True 2.000000 0.0 0.0 0 2.000000 0.0 0.200000 0.0 0.0
160 | 2 0.000000 2.000000 2.0 True 0.000000 0.0 2.0 0 0.000000 2.0 0.000000 0.0 0.0
161 | 3 2.000000 -2.000000 0.0 True 2.000000 0.0 0.0 0 2.000000 0.0 0.200000 0.0 0.0
162 | 4 0.000000 2.000000 2.0 True 0.000000 0.0 2.0 0 0.000000 2.0 0.000000 0.0 0.0
163 | ```
164 |
165 | In the first interval, we charge the battery with `0.444444 MWh` - `0.4 MWh` goes into increasing the battery state of charge from `0.0 MWh` to `0.4 MWh`, with the balance `0.044444 MWh` going to battery losses.
166 |
167 | ### Battery Efficiency
168 |
169 | We can validate the performance of the battery efficiency by checking the losses across different battery efficiencies:
170 |
171 | ```python
172 | import numpy as np
173 | import pandas as pd
174 | import energypylinear as epl
175 |
176 | np.random.seed(42)
177 | prices = np.random.uniform(-100, 100, 12) + 100
178 |
179 | out = []
180 | for efficiency_pct in [1.0, 0.9, 0.8]:
181 | asset = epl.Battery(
182 | power_mw=4,
183 | capacity_mwh=10,
184 | efficiency_pct=efficiency_pct,
185 | electricity_prices=prices,
186 | )
187 | simulation = asset.optimize(
188 | objective="price",
189 | verbose=3
190 | )
191 | results = simulation.results
192 | out.append(
193 | {
194 | "eff_pct": efficiency_pct,
195 | "charge_mwh": results["battery-electric_charge_mwh"].sum(),
196 | "discharge_mwh": results["battery-electric_discharge_mwh"].sum(),
197 | "loss_mwh": results["battery-electric_loss_mwh"].sum(),
198 | "prices_$_mwh": results["site-electricity_prices"].mean(),
199 | "import_mwh": results["site-import_power_mwh"].sum(),
200 | "objective": (results["site-import_power_mwh"] - results["site-export_power_mwh"] * results["site-electricity_prices"]).sum(),
201 | }
202 | )
203 |
204 | print(pd.DataFrame(out))
205 | ```
206 |
207 | ```
208 | eff_pct charge_mwh discharge_mwh loss_mwh prices_$_mwh import_mwh objective
209 | 0 1.0 18.000000 18.0 0.000000 103.197695 18.000000 -3018.344310
210 | 1 0.9 19.111111 17.2 1.911111 103.197695 19.111111 -2893.086854
211 | 2 0.8 20.000000 16.0 4.000000 103.197695 20.000000 -2719.962419
212 | ```
213 |
214 | From the above we observe the following as efficiency decreases:
215 |
216 | - a reduction in the amount discharged `discharge_mwh`,
217 | - an increase in battery losses `loss_mwh`,
218 | - a increase in the objective function, which is an increase in cost.
219 |
220 | ### State of Charge & Power Ratings
221 |
222 | We can demonstrate the state of charge and battery power settings by first optimizing a battery and showing it's plot:
223 |
224 | ```python
225 | import numpy as np
226 | import energypylinear as epl
227 |
228 | np.random.seed(42)
229 | electricity_prices = np.random.normal(100, 10, 10).tolist()
230 |
231 | asset = epl.Battery(power_mw=2, capacity_mwh=4, electricity_prices=electricity_prices)
232 | results = asset.optimize()
233 | asset.plot(results, path="./docs/docs/static/battery.png")
234 | ```
235 |
236 | 
237 |
238 | Takeaways:
239 |
240 | - the battery state of charge is constrained between 0 and 4 MWh,
241 | - the battery power rating is constrained between -2 and 2 MW,
242 | - battery SOC starts empty and ends empty.
243 |
244 | ```python
245 | import numpy as np
246 | import energypylinear as epl
247 |
248 | np.random.seed(42)
249 |
250 | asset = epl.Battery(
251 | power_mw=4,
252 | capacity_mwh=8,
253 | electricity_prices=np.random.normal(100, 10, 10),
254 | initial_charge_mwh=1.0,
255 | final_charge_mwh=3.0
256 | )
257 | results = asset.optimize()
258 | asset.plot(results, path="./docs/docs/static/battery-fast.png")
259 | ```
260 |
261 | 
262 |
263 | Takeaways:
264 |
265 | - battery SOC starts at 1 MWh and ends at 3 MWh.
266 |
--------------------------------------------------------------------------------
/docs/docs/assets/chp.md:
--------------------------------------------------------------------------------
1 | The `epl.CHP` asset is suitable for modelling combined heat and power (CHP) systems.
2 |
3 | It can generate electricity, high & low temperature heat from natural gas.
4 |
5 | ## Assumptions
6 |
7 | The `epl.CHP` is configured with electric, high and low temperature thermal efficiencies. This allows modelling open cycle generators, gas engines and gas turbines.
8 |
9 | When optimizing, we can use interval data for the high and low temperature loads.
10 |
11 | The high and low temperature loads will be met by gas boilers if the CHP chooses not to generate, or cannot meet thermal demands. High temperature heat can be let-down into low temperature heat.
12 |
13 | The `epl.CHP` is allowed to dump both high temperature and low temperature heat.
14 |
15 | ## Use
16 |
17 | You can optimize a single CHP with `epl.CHP`:
18 |
19 | ```python
20 | import energypylinear as epl
21 |
22 | # 100 MWe gas turbine
23 | asset = epl.CHP(
24 | electric_power_max_mw=100,
25 | electric_power_min_mw=50,
26 | electric_efficiency_pct=0.3,
27 | high_temperature_efficiency_pct=0.5,
28 | )
29 |
30 | # 100 MWe gas engine
31 | asset = epl.CHP(
32 | electric_power_max_mw=100,
33 | electric_power_min_mw=10,
34 | electric_efficiency_pct=0.4,
35 | high_temperature_efficiency_pct=0.2,
36 | low_temperature_efficiency_pct=0.2,
37 | electricity_prices=[100, 50, 200, -100, 0, 200, 100, -100],
38 | high_temperature_load_mwh=[100, 50, 200, 40, 0, 200, 100, 100],
39 | low_temperature_load_mwh=20
40 | )
41 |
42 | simulation = asset.optimize()
43 |
44 | assert all(
45 | simulation.results.columns
46 | == [
47 | "site-import_power_mwh",
48 | "site-export_power_mwh",
49 | "site-electricity_prices",
50 | "site-export_electricity_prices",
51 | "site-electricity_carbon_intensities",
52 | "site-gas_prices",
53 | "site-electric_load_mwh",
54 | "site-high_temperature_load_mwh",
55 | "site-low_temperature_load_mwh",
56 | "site-low_temperature_generation_mwh",
57 | "chp-electric_generation_mwh",
58 | "chp-gas_consumption_mwh",
59 | "chp-high_temperature_generation_mwh",
60 | "chp-low_temperature_generation_mwh",
61 | "boiler-high_temperature_generation_mwh",
62 | "boiler-gas_consumption_mwh",
63 | "valve-high_temperature_load_mwh",
64 | "valve-low_temperature_generation_mwh",
65 | "total-electric_generation_mwh",
66 | "total-electric_load_mwh",
67 | "total-high_temperature_generation_mwh",
68 | "total-low_temperature_generation_mwh",
69 | "total-high_temperature_load_mwh",
70 | "total-low_temperature_load_mwh",
71 | "total-gas_consumption_mwh",
72 | "total-electric_charge_mwh",
73 | "total-electric_discharge_mwh",
74 | "total-spills_mwh",
75 | "total-electric_loss_mwh",
76 | "site-electricity_balance_mwh",
77 | ]
78 | )
79 | ```
80 |
--------------------------------------------------------------------------------
/docs/docs/assets/evs.md:
--------------------------------------------------------------------------------
1 | The `epl.EVs` asset is suitable for modelling electric vehicle charging. One asset can operate many electric vehicle chargers that supply electricity to many electric vehicle charge events.
2 |
3 | The electric vehicle asset will optimize the dispatch of the chargers to supply electricity to the charge events. The asset can do both grid to vehicle and vehicle to grid electricity flow with a `allows_evs_discharge` flag.
4 |
5 | ## Assumptions
6 |
7 | Electric vehicle chargers are configured by the charger power output, given as `charger_mws`.
8 |
9 | A `charge_event` is a time interval where an electric vehicle can be charged. This is given as a boolean 2D array, with one binary digit for each charge event, interval pairs.
10 |
11 | Each charge event has a required amount of electricity `charge_event_mwh`, that can be delivered when the `charge_event` is 1. The model is constrained so that each charge event receives all of it's `charge_event_mwh`.
12 |
13 | ## Use
14 |
15 | Optimize two 100 MWe chargers for 4 charge events over 5 intervals:
16 |
17 | ```python
18 | import energypylinear as epl
19 |
20 | electricity_prices = [-100, 50, 30, 50, 40]
21 | charge_events = [
22 | [1, 0, 0, 0, 0],
23 | [0, 1, 1, 1, 0],
24 | [0, 0, 0, 1, 1],
25 | [0, 1, 0, 0, 0],
26 | ]
27 |
28 | # 2 100 MW EV chargers
29 | asset = epl.EVs(
30 | chargers_power_mw=[100, 100],
31 | charge_events_capacity_mwh=[50, 100, 30, 40],
32 | charger_turndown=0.1,
33 | electricity_prices=electricity_prices,
34 | charge_events=charge_events,
35 | include_spill=True
36 | )
37 |
38 | simulation = asset.optimize()
39 |
40 | assert all(
41 | simulation.results.columns
42 | == [
43 | "site-import_power_mwh",
44 | "site-export_power_mwh",
45 | "site-electricity_prices",
46 | "site-export_electricity_prices",
47 | "site-electricity_carbon_intensities",
48 | "site-gas_prices",
49 | "site-electric_load_mwh",
50 | "site-high_temperature_load_mwh",
51 | "site-low_temperature_load_mwh",
52 | "site-low_temperature_generation_mwh",
53 | "spill-electric_generation_mwh",
54 | "spill-electric_load_mwh",
55 | "spill-high_temperature_generation_mwh",
56 | "spill-low_temperature_generation_mwh",
57 | "spill-high_temperature_load_mwh",
58 | "spill-low_temperature_load_mwh",
59 | "spill-gas_consumption_mwh",
60 | "evs-charger-0-electric_charge_mwh",
61 | "evs-charger-0-electric_charge_binary",
62 | "evs-charger-0-electric_discharge_mwh",
63 | "evs-charger-0-electric_discharge_binary",
64 | "evs-charger-0-electric_loss_mwh",
65 | "evs-charger-1-electric_charge_mwh",
66 | "evs-charger-1-electric_charge_binary",
67 | "evs-charger-1-electric_discharge_mwh",
68 | "evs-charger-1-electric_discharge_binary",
69 | "evs-charger-1-electric_loss_mwh",
70 | "evs-charge-event-0-electric_charge_mwh",
71 | "evs-charge-event-0-electric_discharge_mwh",
72 | "evs-charge-event-0-electric_loss_mwh",
73 | "evs-charge-event-1-electric_charge_mwh",
74 | "evs-charge-event-1-electric_discharge_mwh",
75 | "evs-charge-event-1-electric_loss_mwh",
76 | "evs-charge-event-2-electric_charge_mwh",
77 | "evs-charge-event-2-electric_discharge_mwh",
78 | "evs-charge-event-2-electric_loss_mwh",
79 | "evs-charge-event-3-electric_charge_mwh",
80 | "evs-charge-event-3-electric_discharge_mwh",
81 | "evs-charge-event-3-electric_loss_mwh",
82 | "evs-charge-event-0-initial_soc_mwh",
83 | "evs-charge-event-1-initial_soc_mwh",
84 | "evs-charge-event-2-initial_soc_mwh",
85 | "evs-charge-event-3-initial_soc_mwh",
86 | "evs-charge-event-0-final_soc_mwh",
87 | "evs-charge-event-1-final_soc_mwh",
88 | "evs-charge-event-2-final_soc_mwh",
89 | "evs-charge-event-3-final_soc_mwh",
90 | "evs-charger-spill-evs-electric_charge_mwh",
91 | "evs-charger-spill-evs-electric_charge_binary",
92 | "evs-charger-spill-evs-electric_discharge_mwh",
93 | "evs-charger-spill-evs-electric_discharge_binary",
94 | "evs-charger-spill-evs-electric_loss_mwh",
95 | "total-electric_generation_mwh",
96 | "total-electric_load_mwh",
97 | "total-high_temperature_generation_mwh",
98 | "total-low_temperature_generation_mwh",
99 | "total-high_temperature_load_mwh",
100 | "total-low_temperature_load_mwh",
101 | "total-gas_consumption_mwh",
102 | "total-electric_charge_mwh",
103 | "total-electric_discharge_mwh",
104 | "total-spills_mwh",
105 | "total-electric_loss_mwh",
106 | "site-electricity_balance_mwh",
107 | ],
108 | )
109 | ```
110 |
111 | ## Validation
112 |
113 | A natural response when you get access to something someone else built is to wonder - **does this work correctly?**
114 |
115 | This section will give you confidence in the implementation of the EV asset.
116 |
117 | ### Fully Constrained EV Charging
118 |
119 | ```python
120 | import energypylinear as epl
121 |
122 | asset = epl.EVs(
123 | chargers_power_mw=[100, 100],
124 | charge_events_capacity_mwh=[50, 100, 30],
125 | charger_turndown=0.0,
126 | charge_event_efficiency=1.0,
127 | electricity_prices=[-100, 50, 30, 10, 40],
128 | charge_events=[
129 | [1, 0, 0, 0, 0],
130 | [0, 1, 0, 0, 0],
131 | [0, 0, 1, 0, 0],
132 | ]
133 | )
134 | simulation = asset.optimize()
135 | asset.plot(simulation, path="./docs/docs/static/ev-validation-1.png")
136 | ```
137 |
138 | The third charger is the spill charger.
139 |
140 | 
141 |
142 | ### Expanding a Charge Event Window
143 |
144 | Let's expand out the charge event window to the last three intervals for the last charge event:
145 |
146 | ```python
147 | import energypylinear as epl
148 |
149 | asset = epl.EVs(
150 | chargers_power_mw=[100, 100],
151 | charge_events_capacity_mwh=[50, 100, 30],
152 | charger_turndown=0.0,
153 | charge_event_efficiency=1.0,
154 | electricity_prices=[-100, 50, 300, 10, 40],
155 | charge_events=[
156 | [1, 0, 0, 0, 0],
157 | [0, 1, 0, 0, 0],
158 | [0, 0, 1, 1, 1],
159 | ]
160 | )
161 | simulation = asset.optimize()
162 | asset.plot(simulation, path="./docs/docs/static/ev-validation-2.png")
163 | ```
164 |
165 | Now we see that the charge has happened in interval 3, this is because electricity prices are lowest in this interval.
166 |
167 | 
168 |
169 | ### Overlapping Charge Events
170 |
171 | When charge events overlap at low prices, both (but only two) chargers are used:
172 |
173 | ```python
174 | import energypylinear as epl
175 |
176 | asset = epl.EVs(
177 | chargers_power_mw=[100, 100],
178 | charge_events_capacity_mwh=[50, 100, 30],
179 | charger_turndown=0.0,
180 | charge_event_efficiency=1.0,
181 | electricity_prices=[-100, 50, 300, 10, 40],
182 | charge_events=[
183 | [1, 0, 0, 1, 0],
184 | [0, 1, 1, 1, 1],
185 | [0, 0, 1, 1, 1],
186 | ]
187 | )
188 | simulation = asset.optimize()
189 | asset.plot(simulation, path="./docs/docs/static/ev-validation-3.png")
190 | ```
191 |
192 | 
193 |
194 | ### Adding V2G
195 |
196 | ```python
197 | import energypylinear as epl
198 |
199 | asset = epl.EVs(
200 | chargers_power_mw=[100, 100],
201 | charge_events_capacity_mwh=[50, 100, 30],
202 | charger_turndown=0.0,
203 | charge_event_efficiency=1.0,
204 | electricity_prices=[-100, 50, 300, 10, 40],
205 | charge_events=[
206 | [1, 0, 0, 0, 0],
207 | [0, 1, 1, 1, 1],
208 | [0, 0, 1, 1, 1],
209 | ],
210 | )
211 | simulation = asset.optimize(
212 | flags=epl.Flags(allow_evs_discharge=True)
213 | )
214 | asset.plot(simulation, path="./docs/docs/static/ev-validation-4.png")
215 | ```
216 |
217 | 
218 |
219 | The key takeaway here is that we discharge during interval 2. All our charge events still end up at the correct state of charge at the end of the program.
220 |
221 | ### Spill Chargers
222 |
223 | ```python
224 | import energypylinear as epl
225 |
226 | asset = epl.EVs(
227 | chargers_power_mw=[100, 100],
228 | charge_events_capacity_mwh=[50, 100, 30, 500],
229 | charger_turndown=0.0,
230 | charge_event_efficiency=1.0,
231 | electricity_prices=[-100, 50, 300, 10, 40],
232 | charge_events=[
233 | [1, 0, 0, 0, 0],
234 | [0, 1, 1, 1, 1],
235 | [0, 0, 1, 1, 1],
236 | [1, 0, 0, 0, 0],
237 | ],
238 | )
239 | simulation = asset.optimize(
240 | flags=epl.Flags(allow_evs_discharge=True)
241 | )
242 | asset.plot(simulation, path="./docs/docs/static/ev-validation-5.png")
243 | ```
244 |
245 | Key takeaway here is the use of the spill charger - we have a 500 MWh charge event, but only 200 MWh of capacity. We meet the remaining demand from a spill charger.
246 |
247 | This allows the linear program to be feasible, while communicating directly which intervals or charge events are causing the mismatch between charge event demand and spill charger capacity.
248 |
249 | 
250 |
--------------------------------------------------------------------------------
/docs/docs/assets/renewable-generator.md:
--------------------------------------------------------------------------------
1 | The `epl.RenewableGenerator` asset is suitable for modelling wind or solar generation.
2 |
3 | ## Use
4 |
5 | ```python
6 | import energypylinear as epl
7 |
8 | asset = epl.RenewableGenerator(
9 | electricity_prices=[1.0, -0.5],
10 | electric_generation_mwh=[100, 100],
11 | electric_generation_lower_bound_pct=0.5,
12 | name="wind",
13 | )
14 | simulation = asset.optimize(objective="price")
15 |
16 | assert all(
17 | simulation.results.columns
18 | == [
19 | "site-import_power_mwh",
20 | "site-export_power_mwh",
21 | "site-electricity_prices",
22 | "site-export_electricity_prices",
23 | "site-electricity_carbon_intensities",
24 | "site-gas_prices",
25 | "site-electric_load_mwh",
26 | "site-high_temperature_load_mwh",
27 | "site-low_temperature_load_mwh",
28 | "site-low_temperature_generation_mwh",
29 | "wind-electric_generation_mwh",
30 | "total-electric_generation_mwh",
31 | "total-electric_load_mwh",
32 | "total-high_temperature_generation_mwh",
33 | "total-low_temperature_generation_mwh",
34 | "total-high_temperature_load_mwh",
35 | "total-low_temperature_load_mwh",
36 | "total-gas_consumption_mwh",
37 | "total-electric_charge_mwh",
38 | "total-electric_discharge_mwh",
39 | "total-spills_mwh",
40 | "total-electric_loss_mwh",
41 | "site-electricity_balance_mwh",
42 | ],
43 | )
44 | ```
45 |
46 | This renewable generator will turn down when electricity prices are negative.
47 |
48 | ## Validation
49 |
50 | A natural response when you get access to something someone else built is to wonder - does this work correctly?
51 |
52 | This section will give you confidence in the implementation of the renewable generator asset.
53 |
54 | ### Carbon Dispatch Behaviour
55 |
56 | Let's optimize the renewable generator asset in two intervals:
57 |
58 | 1. a positive import electricity carbon intensity of `1.0 tC/MWh`,
59 | 2. a negative import electricity carbon intensity of `-0.5 tC/MWh`.
60 |
61 | If we optimize our `epl.RenewableGenerator` asset with a lower bound on the electricity generation of `1.0`, we generate the full `100 MW` in each interval:
62 |
63 | ```python
64 | import energypylinear as epl
65 |
66 | electricity_carbon_intensities = [1.0, -0.5]
67 | electric_generation_mwh=[100, 100]
68 | electric_generation_lower_bound_pct=1.0
69 |
70 | asset = epl.RenewableGenerator(
71 | electricity_carbon_intensities=electricity_carbon_intensities,
72 | electric_generation_mwh=electric_generation_mwh,
73 | name="wind",
74 | electric_generation_lower_bound_pct=electric_generation_lower_bound_pct
75 | )
76 | simulation = asset.optimize(objective="carbon", verbose=3)
77 | print(simulation.results[
78 | [
79 | "site-electricity_carbon_intensities",
80 | "site-export_power_mwh",
81 | "wind-electric_generation_mwh",
82 | ]
83 | ])
84 | ```
85 |
86 | ```
87 | site-electricity_carbon_intensities site-export_power_mwh wind-electric_generation_mwh
88 | 0 1.0 100.0 100.0
89 | 1 -0.5 100.0 100.0
90 | ```
91 |
92 | If we change our lower bound to `0.5`, our renewable generator asset will generate less electricity during the second, negative carbon intensity interval:
93 |
94 | ```python
95 | import energypylinear as epl
96 |
97 | electricity_carbon_intensities = [1.0, -0.5]
98 | electric_generation_mwh=[100, 100]
99 | electric_generation_lower_bound_pct=0.5
100 |
101 | asset = epl.RenewableGenerator(
102 | electricity_carbon_intensities=electricity_carbon_intensities,
103 | electric_generation_mwh=electric_generation_mwh,
104 | name="wind",
105 | electric_generation_lower_bound_pct=electric_generation_lower_bound_pct
106 | )
107 | simulation = asset.optimize(objective="carbon", verbose=3)
108 | print(simulation.results[
109 | [
110 | "site-electricity_carbon_intensities",
111 | "site-export_power_mwh",
112 | "wind-electric_generation_mwh",
113 | ]
114 | ])
115 | ```
116 |
117 | ```
118 | site-electricity_carbon_intensities site-export_power_mwh wind-electric_generation_mwh
119 | 0 1.0 100.0 100.0
120 | 1 -0.5 50.0 50.0
121 | ```
122 |
--------------------------------------------------------------------------------
/docs/docs/examples/renewable-battery-export.py:
--------------------------------------------------------------------------------
1 | """Example of a site with a renewable generator and a battery exporting to the grid."""
2 | import energypylinear as epl
3 |
4 | assets = [
5 | epl.Battery(power_mw=10, capacity_mwh=20, efficiency_pct=0.9),
6 | epl.RenewableGenerator(
7 | electric_generation_mwh=[10, 20, 30, 20, 10],
8 | electric_generation_lower_bound_pct=0.5,
9 | name="solar",
10 | ),
11 | ]
12 |
13 | site = epl.Site(
14 | assets=assets,
15 | electricity_carbon_intensities=[0.5, -0.5, 0.5, 0.5, -0.5],
16 | export_limit_mw=25,
17 | )
18 |
19 | simulation = site.optimize(objective="carbon")
20 |
--------------------------------------------------------------------------------
/docs/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | # hi
2 |
--------------------------------------------------------------------------------
/docs/docs/how-to/battery-degradation.md:
--------------------------------------------------------------------------------
1 | Battery degradation is where battery performance reduces with time or battery use.
2 |
3 | The performance of the battery is defined by the parameters of power (MW), capacity (MWh) and efficiency (%).
4 |
5 | `energypylinear` does not model battery degradation within a single simulation - degradation can be handled by splitting up the battery lifetime into multiple simulations.
6 |
7 | ## Modelling a Single Year in Monthly Chunks
8 |
9 | To handle battery degradation over a year, we will split the year into 12 months and run a simulation for each month:
10 |
11 |
12 | ```python
13 | import numpy as np
14 | import pandas as pd
15 |
16 | import energypylinear as epl
17 |
18 | np.random.seed(42)
19 | days = 35
20 | dataset = pd.DataFrame({
21 | "timestamp": pd.date_range("2021-01-01", periods=days * 24, freq="h"),
22 | "prices": np.random.normal(-1000, 1000, days * 24) + 100
23 | })
24 | battery_params = {
25 | "power_mw": 4,
26 | "capacity_mwh": 10,
27 | "efficiency_pct": 0.9,
28 | "freq_mins": 60
29 | }
30 |
31 | results = []
32 | objs = []
33 | for month, group in dataset.groupby(dataset['timestamp'].dt.month):
34 | print(f"Month {month}")
35 | battery = epl.Battery(electricity_prices=group['prices'], **battery_params)
36 | simulation = battery.optimize(verbose=3)
37 | results.append(simulation.results)
38 | objs.append(simulation.status.objective)
39 |
40 | year = pd.concat(results)
41 | assert year.shape[0] == days * 24
42 | account = epl.get_accounts(year, verbose=3)
43 | np.testing.assert_allclose(account.profit, -1 * sum(objs))
44 | print(account)
45 | ```
46 |
47 | ```
48 | Month 1
49 | Month 2
50 |
51 | ```
52 |
53 | The results above do not include any battery degradation - battery parameters are the same at the start of each month.
54 |
55 | ## Modelling Degradation
56 |
57 | To model degradation, we need to take a view on how our battery parameters change over time.
58 |
59 | For our simulation, we will model:
60 |
61 | - battery power decays by 0.1 MW for each 150 MWh of battery charge,
62 | - battery capacity decays by 0.1 MWh for each 150 MWh of battery charge,
63 | - battery efficiency decays by 0.1% over 30 days.
64 |
65 |
66 | ```python
67 | def get_battery_params(cumulative_charge_mwh: float = 0, cumulative_days: float = 0) -> dict:
68 | """Get degraded battery parameters based on usage and time."""
69 | power_decay_mw_per_mwh = 0.1 / 150
70 | capacity_decay_mwh_per_mwh = 0.1 / 150
71 | efficiency_decay_pct_per_day = 0.1 / 30
72 | return {
73 | "power_mw": 4 - power_decay_mw_per_mwh * cumulative_charge_mwh,
74 | "capacity_mwh": 10 - capacity_decay_mwh_per_mwh * cumulative_charge_mwh,
75 | "efficiency_pct": 0.9 - efficiency_decay_pct_per_day * cumulative_days,
76 | "freq_mins": 60
77 | }
78 | ```
79 |
80 | For a fresh battery, our battery parameters are:
81 |
82 |
83 | ```python
84 | print(get_battery_params())
85 | ```
86 |
87 | ```
88 | {'power_mw': 4.0, 'capacity_mwh': 10.0, 'efficiency_pct': 0.9, 'freq_mins': 60}
89 | ```
90 |
91 | For a battery that has been charged with 300 MWh over 60 days, our battery parameters are:
92 |
93 |
94 | ```python
95 | print(get_battery_params(cumulative_charge_mwh=300, cumulative_days=60))
96 | ```
97 |
98 | ```
99 | {'power_mw': 3.8, 'capacity_mwh': 9.8, 'efficiency_pct': 0.7, 'freq_mins': 60}
100 | ```
101 |
102 | ## Modelling a Single Year in Monthly Chunks with Degradation
103 |
104 | We can include our battery degradation model in our simulation by keeping track of our battery usage and updating the battery parameters at the start of each month:
105 |
106 |
107 | ```python
108 | import collections
109 |
110 | results = []
111 | cumulative = collections.defaultdict(float)
112 | for month, group in dataset.groupby(dataset['timestamp'].dt.month):
113 | battery_params = get_battery_params(
114 | cumulative_charge_mwh=cumulative['charge_mwh'],
115 | cumulative_days=cumulative['days']
116 | )
117 | print(f"Month: {month}, Battery Params: {battery_params}")
118 | battery = epl.Battery(electricity_prices=group['prices'], **battery_params)
119 | simulation = battery.optimize(verbose=3)
120 | results.append(simulation.results)
121 | cumulative['charge_mwh'] += simulation.results['battery-electric_charge_mwh'].sum()
122 | cumulative['days'] += group.shape[0] / 24
123 |
124 | year = pd.concat(results)
125 | assert year.shape[0] == days * 24
126 | account = epl.get_accounts(year, verbose=3)
127 | print(account)
128 | ```
129 |
130 | ```
131 | Month: 1, Battery Params: {'power_mw': 4.0, 'capacity_mwh': 10.0, 'efficiency_pct': 0.9, 'freq_mins': 60}
132 | Month: 2, Battery Params: {'power_mw': 3.0663703705399996, 'capacity_mwh': 9.06637037054, 'efficiency_pct': 0.7966666666666666, 'freq_mins': 60}
133 |
134 | ```
135 |
136 | ## Full Example
137 |
138 | ```python
139 | import collections
140 |
141 | import numpy as np
142 | import pandas as pd
143 |
144 | import energypylinear as epl
145 |
146 | def get_battery_params(cumulative_charge_mwh: float = 0, cumulative_days: float = 0) -> dict:
147 | """Get degraded battery parameters based on usage and time."""
148 | power_decay_mw_per_mwh = 0.1 / 150
149 | capacity_decay_mwh_per_mwh = 0.1 / 150
150 | efficiency_decay_pct_per_day = 0.1 / 30
151 | return {
152 | "power_mw": 4 - power_decay_mw_per_mwh * cumulative_charge_mwh,
153 | "capacity_mwh": 10 - capacity_decay_mwh_per_mwh * cumulative_charge_mwh,
154 | "efficiency_pct": 0.9 - efficiency_decay_pct_per_day * cumulative_days,
155 | "freq_mins": 60
156 | }
157 |
158 | np.random.seed(42)
159 | days = 35
160 | dataset = pd.DataFrame({
161 | "timestamp": pd.date_range("2021-01-01", periods=days * 24, freq="h"),
162 | "prices": np.random.normal(-1000, 1000, days * 24) + 100
163 | })
164 |
165 | results = []
166 | cumulative = collections.defaultdict(float)
167 | for month, group in dataset.groupby(dataset['timestamp'].dt.month):
168 | battery_params = get_battery_params(
169 | cumulative_charge_mwh=cumulative['charge_mwh'],
170 | cumulative_days=cumulative['days']
171 | )
172 | print(f"Month: {month}, Battery Params: {battery_params}")
173 | battery = epl.Battery(electricity_prices=group['prices'], **battery_params)
174 | simulation = battery.optimize(verbose=3)
175 | results.append(simulation.results)
176 | cumulative['charge_mwh'] += simulation.results['battery-electric_charge_mwh'].sum()
177 | cumulative['days'] += group.shape[0] / 24
178 |
179 | year = pd.concat(results)
180 | assert year.shape[0] == days * 24
181 | account = epl.get_accounts(year, verbose=3)
182 | print(account)
183 | ```
184 |
185 | ```
186 | Month: 1, Battery Params: {'power_mw': 4.0, 'capacity_mwh': 10.0, 'efficiency_pct': 0.9, 'freq_mins': 60}
187 | Month: 2, Battery Params: {'power_mw': 3.0663703705399996, 'capacity_mwh': 9.06637037054, 'efficiency_pct': 0.7966666666666666, 'freq_mins': 60}
188 |
189 | ```
190 |
--------------------------------------------------------------------------------
/docs/docs/how-to/custom-constraints.md:
--------------------------------------------------------------------------------
1 | Constraints define the feasible region of a linear program. They are how you control what is and isn't possible in a simulation.
2 |
3 | The assets and site in `energypylinear` apply built-in constraints to the linear program. In addition, you can define your own custom constraints.
4 |
5 | **A custom constraint allows you to control what can and cannot happen in an `energypylinear` simulation**.
6 |
7 | ## Custom Constraint
8 |
9 | The `epl.Constraint` represents a single custom constraint.
10 |
11 | A custom constraint has a left hand side, a right hand side and a sense.
12 |
13 |
14 | ```python
15 | --8<-- "energypylinear/constraints.py:constraint"
16 | ```
17 |
18 | It also has an option for configuring how the constraint is aggregated over the simulation intervals.
19 |
20 | ## Constraint Terms
21 |
22 | Both the left and right hand sides of a custom constraint are lists of constraint terms. A constraint term can be either a constant, an `epl.ConstraintTerm` or a dictionary.
23 |
24 | The `epl.ConstraintTerm` represents a single term in a constraint:
25 |
26 |
27 | ```python
28 | --8<-- "energypylinear/constraints.py:constraint-term"
29 | ```
30 |
31 | ## Examples
32 |
33 | ### Limiting Battery Cycles
34 |
35 | The example below shows how to optimize a battery with a custom constraint on battery cycles.
36 |
37 | We define battery cycles as the sum of the total battery charge and discharge, and constrain it to be less than or equal to 15 cycles of 2 MWh per cycle:
38 |
39 | ```python
40 | import energypylinear as epl
41 | import numpy as np
42 |
43 | np.random.seed(42)
44 | cycle_limit_mwh = 30
45 | asset = epl.Battery(
46 | power_mw=1,
47 | capacity_mwh=2,
48 | efficiency_pct=0.98,
49 | electricity_prices=np.random.normal(0.0, 1000, 48 * 7),
50 | constraints=[
51 | epl.Constraint(
52 | lhs=[
53 | epl.ConstraintTerm(
54 | asset_type="battery", variable="electric_charge_mwh"
55 | ),
56 | epl.ConstraintTerm(
57 | asset_type="battery", variable="electric_discharge_mwh"
58 | ),
59 | ],
60 | rhs=cycle_limit_mwh,
61 | sense="le",
62 | interval_aggregation="sum",
63 | )
64 | ],
65 | )
66 | simulation = asset.optimize(verbose=3)
67 | total_cycles = simulation.results.sum()[
68 | ["battery-electric_charge_mwh", "battery-electric_discharge_mwh"]
69 | ].sum()
70 | print(f"{total_cycles=}")
71 | ```
72 |
73 | After simulation we can see our total cycles are constrained to an upper limit of 30 (with a small floating point error):
74 |
75 | ```
76 | total_cycles=30.000000002
77 | ```
78 |
79 | ### Constraining Total Generation
80 |
81 | The example below shows how to use a custom constraint to constrain the total generation in a site.
82 |
83 | We define a site with a solar and electric generator asset, with the available solar power increasing with time:
84 |
85 | ```python
86 | import energypylinear as epl
87 | import numpy as np
88 |
89 | np.random.seed(42)
90 |
91 | idx_len = 4
92 | generator_size = 100
93 | solar_gen = [10.0, 20, 30, 40]
94 | site = epl.Site(
95 | assets=[
96 | epl.RenewableGenerator(
97 | electric_generation_mwh=solar_gen,
98 | name="solar",
99 | electric_generation_lower_bound_pct=0.0,
100 | ),
101 | epl.CHP(electric_power_max_mw=generator_size, electric_efficiency_pct=0.5),
102 | ],
103 | electricity_prices=np.full(idx_len, 400),
104 | gas_prices=10,
105 | constraints=[
106 | {
107 | "lhs": {"variable": "electric_generation_mwh", "asset_type": "*"},
108 | "rhs": 25,
109 | "sense": "le",
110 | }
111 | ],
112 | )
113 | simulation = site.optimize(verbose=3)
114 | print(
115 | simulation.results[
116 | [
117 | "chp-electric_generation_mwh",
118 | "solar-electric_generation_mwh",
119 | "total-electric_generation_mwh",
120 | ]
121 | ]
122 | )
123 | ```
124 |
125 | As solar generation becomes available, the CHP electric generation decreases to keep the total site electric generation at 25 MWh:
126 |
127 | ```
128 | chp-electric_generation_mwh solar-electric_generation_mwh total-electric_generation_mwh
129 | 0 15.0 10.0 25.0
130 | 1 5.0 20.0 25.0
131 | 2 0.0 25.0 25.0
132 | 3 0.0 25.0 25.0
133 | ```
134 |
--------------------------------------------------------------------------------
/docs/docs/how-to/custom-interval-data.md:
--------------------------------------------------------------------------------
1 | Interval data is a key input to an `energypylinear` simulation.
2 |
3 | By default, `energypylinear` accepts interval data for things like electricity prices, carbon intensities and site electricity and heat consumption:
4 |
5 |
6 | ```python
7 | --8<-- "energypylinear/assets/site.py:site"
8 | ```
9 |
10 | These arguments are passed to the `SiteIntervalData` object, which is responsible for managing interval data for a site:
11 |
12 | ```python
13 | import energypylinear as epl
14 |
15 | asset = epl.Battery(electricity_prices=[100, 50, 200])
16 | print(asset.site.cfg.interval_data)
17 | ```
18 |
19 | ```
20 | electricity_prices=array([100., 50., 200.]) export_electricity_prices=array([100., 50., 200.]) electricity_carbon_intensities=array([0.1, 0.1, 0.1]) gas_prices=array([20, 20, 20]) electric_load_mwh=array([0, 0, 0]) high_temperature_load_mwh=array([0, 0, 0]) low_temperature_load_mwh=array([0, 0, 0]) low_temperature_generation_mwh=array([0, 0, 0]) idx=array([0, 1, 2])
21 | ```
22 |
23 | ## Custom Interval Data
24 |
25 | Often you will want to use different interval data for your simulation - for example modelling site network charges.
26 |
27 | Additional keyword arguments passed into a site or asset `__init__` are attempted to be parsed into interval data. These will be parsed into site interval data, even if passed into an asset.
28 |
29 | For example, when we pass in a `network_charge` argument, we end up with a `network_charge` attribute on our `asset.site.cfg.interval_data` object:
30 |
31 | ```python
32 | import energypylinear as epl
33 |
34 | electricity_prices = [100, 50, 200]
35 | asset = epl.Battery(electricity_prices=[100, 50, 200], network_charges=[10, 20, 30])
36 | print(asset.site.cfg.interval_data)
37 | ```
38 |
39 | ```
40 | electricity_prices=array([100., 50., 200.]) export_electricity_prices=array([100., 50., 200.]) electricity_carbon_intensities=array([0.1, 0.1, 0.1]) gas_prices=array([20, 20, 20]) electric_load_mwh=array([0, 0, 0]) high_temperature_load_mwh=array([0, 0, 0]) low_temperature_load_mwh=array([0, 0, 0]) low_temperature_generation_mwh=array([0, 0, 0]) idx=array([0, 1, 2]) network_charges=array([10., 20., 30.])
41 | ```
42 |
43 | ## Custom Interval Data in Simulation Results
44 |
45 | All custom interval data will appear in the simulation results:
46 |
47 | ```python
48 | import energypylinear as epl
49 |
50 | asset = epl.Battery(electricity_prices=[100, 50, 200], network_charges=[10, 20, 30])
51 | simulation = asset.optimize(verbose=3)
52 | print(simulation.results["site-network_charges"])
53 | ```
54 |
55 | ```
56 | 0 10.0
57 | 1 20.0
58 | 2 30.0
59 | Name: site-network_charges, dtype: float64
60 | ```
61 |
62 | ## Custom Interval Data in Custom Objective Functions
63 |
64 | Custom interval data can be used in a custom objective function:
65 |
66 | ```python
67 | import energypylinear as epl
68 |
69 | asset = epl.Battery(electricity_prices=[100, 50, 200], network_charges=[10, 20, 30])
70 | simulation = asset.optimize(
71 | objective={
72 | "terms": [
73 | {
74 | "asset_type": "site",
75 | "variable": "import_power_mwh",
76 | "interval_data": "electricity_prices",
77 | },
78 | {
79 | "asset_type": "site",
80 | "variable": "export_power_mwh",
81 | "interval_data": "electricity_prices",
82 | "coefficient": -1,
83 | },
84 | ]
85 | },
86 | verbose=3
87 | )
88 | ```
89 |
--------------------------------------------------------------------------------
/docs/docs/how-to/dispatch-forecast.md:
--------------------------------------------------------------------------------
1 | `energypylinear` has the ability to optimize for both actuals & forecasts.
2 |
3 | An asset (or site) can be used to model the variance between optimizing for actual & forecast prices.
4 |
5 | ## Setup Interval Data
6 |
7 |
8 | ```python
9 | electricity_prices = [100, 50, 200, -100, 0, 200, 100, -100]
10 | forecasts = [-100, 0, 200, 100, -100, 100, 50, 200]
11 | ```
12 |
13 | ## Optimize with Perfect Foresight
14 |
15 |
16 | ```python
17 | import energypylinear as epl
18 |
19 | asset = epl.Battery(
20 | power_mw=2,
21 | capacity_mwh=4,
22 | efficiency_pct=0.9,
23 | electricity_prices=electricity_prices
24 | )
25 | actual = asset.optimize(verbose=3)
26 | perfect_foresight = epl.get_accounts(actual.results, verbose=3)
27 | print(f"{perfect_foresight=}")
28 | ```
29 |
30 | ```
31 | perfect_foresight=
32 | ```
33 |
34 | ## Optimize to a Forecast
35 |
36 |
37 | ```python
38 | import energypylinear as epl
39 |
40 | asset = epl.Battery(
41 | power_mw=2,
42 | capacity_mwh=4,
43 | efficiency_pct=0.9,
44 | electricity_prices=forecasts
45 | )
46 | forecast = asset.optimize(verbose=3)
47 | forecast_account = epl.get_accounts(
48 | forecast.results,
49 | price_results=actual.results,
50 | verbose=3
51 | )
52 | print(f"{forecast_account=}")
53 | ```
54 |
55 | ```
56 | forecast_account=
57 | ```
58 |
59 | ## Calculate Variance Between Accounts
60 |
61 |
62 | ```python
63 | variance = perfect_foresight - forecast_account
64 | print(f"{variance=}")
65 | ```
66 |
67 | ```
68 | variance=
69 | ```
70 |
71 | ## Full Example
72 |
73 | ```python
74 | import io
75 |
76 | import pandas as pd
77 |
78 | import energypylinear as epl
79 |
80 | # price and forecast csv data
81 | raw = """
82 | Timestamp,Trading Price [$/MWh],Predispatch Forecast [$/MWh]
83 | 2018-07-01 17:00:00,177.11,97.58039000000001
84 | 2018-07-01 17:30:00,135.31,133.10307
85 | 2018-07-01 18:00:00,143.21,138.59978999999998
86 | 2018-07-01 18:30:00,116.25,128.09559
87 | 2018-07-01 19:00:00,99.97,113.29413000000001
88 | 2018-07-01 19:30:00,99.71,113.95063
89 | 2018-07-01 20:00:00,97.81,105.5491
90 | 2018-07-01 20:30:00,96.1,102.99768
91 | 2018-07-01 21:00:00,98.55,106.34366000000001
92 | 2018-07-01 21:30:00,95.78,91.82700000000001
93 | 2018-07-01 22:00:00,98.46,87.45
94 | 2018-07-01 22:30:00,91.88,85.65775
95 | 2018-07-01 23:00:00,91.69,85.0
96 | 2018-07-01 23:30:00,101.2,85.0
97 | 2018-07-02 00:00:00,139.55,80.99999
98 | 2018-07-02 00:30:00,102.9,75.85762
99 | 2018-07-02 01:00:00,83.86,67.86758
100 | 2018-07-02 01:30:00,71.1,70.21946
101 | 2018-07-02 02:00:00,60.35,62.151
102 | 2018-07-02 02:30:00,56.01,62.271919999999994
103 | 2018-07-02 03:00:00,51.22,56.79063000000001
104 | 2018-07-02 03:30:00,48.55,53.8532
105 | 2018-07-02 04:00:00,55.17,53.52591999999999
106 | 2018-07-02 04:30:00,56.21,49.57504
107 | 2018-07-02 05:00:00,56.32,48.42244
108 | 2018-07-02 05:30:00,58.79,54.15495
109 | 2018-07-02 06:00:00,73.32,58.01054
110 | 2018-07-02 06:30:00,80.89,68.31508000000001
111 | 2018-07-02 07:00:00,88.43,85.0
112 | 2018-07-02 07:30:00,201.43,119.73926999999999
113 | 2018-07-02 08:00:00,120.33,308.88984
114 | 2018-07-02 08:30:00,113.26,162.32117
115 | """
116 | data = pd.read_csv(io.StringIO(raw))
117 |
118 | # optimize for actuals
119 | asset = epl.Battery(
120 | power_mw=2,
121 | capacity_mwh=4,
122 | efficiency_pct=0.9,
123 | electricity_prices=data["Trading Price [$/MWh]"].values,
124 | freq_mins=30,
125 | )
126 | actuals = asset.optimize(verbose=3)
127 |
128 | # optimize for forecasts
129 | asset = epl.Battery(
130 | power_mw=2,
131 | capacity_mwh=4,
132 | efficiency_pct=0.9,
133 | electricity_prices=data["Predispatch Forecast [$/MWh]"].values,
134 | freq_mins=30,
135 | )
136 | forecasts = asset.optimize(verbose=3)
137 |
138 | # calculate the variance between accounts
139 | actual_account = epl.get_accounts(
140 | actuals.results, verbose=3
141 |
142 | )
143 | forecast_account = epl.get_accounts(
144 | forecasts.results,
145 | price_results=actuals.results,
146 | verbose=3
147 | )
148 | variance = actual_account - forecast_account
149 |
150 | print(f"actuals: {actual_account}")
151 | print(f"forecasts: {forecast_account}")
152 | print(f"variance: {variance}")
153 | print(
154 | f"\nforecast error: $ {-1 * variance.cost:2.2f} pct: {100 * variance.cost / actual_account.cost:2.1f} %"
155 | )
156 | ```
157 |
158 | ```
159 | actuals:
160 | forecasts:
161 | variance:
162 |
163 | forecast error: $ 92.97 pct: 28.5 %
164 | ```
165 |
--------------------------------------------------------------------------------
/docs/docs/how-to/dispatch-site.md:
--------------------------------------------------------------------------------
1 | # Multiple Assets with the Site API
2 |
3 | `energypylinear` can optimize many assets in a single linear program.
4 |
5 | The `epl.Site` accepts a list of `energypylinear` asset models, like `epl.Battery` or `epl.RenewableGenerator`.
6 |
7 | Below are examples of typical configurations of multiple energy assets using a `epl.Site`.
8 |
9 | ## Fast & Slow Battery
10 |
11 | Optimize a fast and slow battery alongside each other:
12 |
13 | ```python
14 | import energypylinear as epl
15 |
16 | site = epl.Site(
17 | assets=[
18 | epl.Battery(
19 | power_mw=4.0,
20 | capacity_mwh=1.0,
21 | initial_charge_mwh=1,
22 | final_charge_mwh=1
23 | ),
24 | epl.Battery(
25 | power_mw=2.0,
26 | capacity_mwh=4.0,
27 | initial_charge_mwh=4.0,
28 | final_charge_mwh=0.0,
29 | name="battery-2",
30 | ),
31 | ],
32 | electricity_prices=[100.0, 50, 200, -100, 0, 200, 100, -100],
33 | freq_mins=60,
34 | )
35 |
36 | simulation = site.optimize()
37 | ```
38 |
39 | ## Battery & EV Chargers
40 |
41 | Optimize a battery next to an EV charging station:
42 |
43 | ```python
44 | import energypylinear as epl
45 |
46 | site = epl.Site(
47 | assets=[
48 | epl.Battery(
49 | power_mw=2.0,
50 | capacity_mwh=4.0,
51 | initial_charge_mwh=1,
52 | final_charge_mwh=3,
53 | ),
54 | epl.EVs(
55 | chargers_power_mw=[100, 100],
56 | charger_turndown=0.1,
57 | charge_events=[
58 | [1, 0, 0, 0, 0, 0, 0, 0],
59 | [0, 1, 1, 1, 0, 0, 0, 0],
60 | [0, 0, 0, 1, 1, 0, 0, 0],
61 | [0, 1, 0, 0, 0, 0, 0, 0],
62 | ],
63 | charge_events_capacity_mwh=[50, 100, 30, 40],
64 | ),
65 | ],
66 | electricity_prices=[100.0, 50, 200, -100, 0, 200, 100, -100],
67 | freq_mins=60,
68 | )
69 |
70 | simulation = site.optimize()
71 | ```
72 |
73 | ## Battery & CHP
74 |
75 | Optimize an electric battery alongside a gas fired CHP:
76 |
77 | ```python
78 | import energypylinear as epl
79 |
80 | site = epl.Site(
81 | assets=[
82 | epl.Battery(
83 | power_mw=2.0,
84 | capacity_mwh=4.0,
85 | initial_charge_mwh=1,
86 | final_charge_mwh=3
87 | ),
88 | epl.CHP(
89 | electric_power_max_mw=100,
90 | electric_power_min_mw=30,
91 | electric_efficiency_pct=0.4,
92 | ),
93 | ],
94 | electricity_prices=[100.0, 50, 200, -100, 0, 200, 100, -100],
95 | freq_mins=60,
96 | )
97 |
98 | simulation = site.optimize()
99 | ```
100 |
--------------------------------------------------------------------------------
/docs/docs/how-to/network-charges.md:
--------------------------------------------------------------------------------
1 | `energypylinear` has the ability to optimize for a network charge.
2 |
3 | A network charge is a tariff applied to a site based on the power consumed in certain intervals. It's often set to incentive reductions in demand during peak periods.
4 |
5 | ## No Network Charge
6 |
7 | ### Asset, Interval Data and Objective Function
8 |
9 | First we will setup a battery with no network charge, and optimize it for electricity prices:
10 |
11 |
12 | ```python
13 | import energypylinear as epl
14 |
15 | electricity_prices = [50, 100, 150]
16 | asset = epl.Battery(electricity_prices=electricity_prices, efficiency=0.9)
17 | bau = asset.optimize(
18 | {
19 | "terms": [
20 | {
21 | "asset_type": "site",
22 | "variable": "import_power_mwh",
23 | "interval_data": "electricity_prices",
24 | },
25 | {
26 | "asset_type": "site",
27 | "variable": "export_power_mwh",
28 | "interval_data": "electricity_prices",
29 | "coefficient": -1,
30 | },
31 | ]
32 | },
33 | verbose=5
34 | )
35 | ```
36 |
37 | ### Results
38 |
39 | We can then calculate the net import and battery charge:
40 |
41 |
42 | ```python
43 | results = bau.results
44 | results["battery-net_charge_mwh"] = (
45 | results["battery-electric_charge_mwh"] - results["battery-electric_discharge_mwh"]
46 | )
47 | results["site-net_import_mwh"] = (
48 | results["site-import_power_mwh"] - results["site-export_power_mwh"]
49 | )
50 | print(
51 | bau.results[
52 | [
53 | "site-electricity_prices",
54 | "site-net_import_mwh",
55 | "battery-net_charge_mwh",
56 | "battery-electric_final_charge_mwh",
57 | ]
58 | ]
59 | )
60 | ```
61 |
62 | As expected, our battery charges during the first two intervals when electricity prices are low, and discharges during the third interval when prices are high:
63 |
64 | ```
65 | site-electricity_prices site-net_import_mwh battery-net_charge_mwh battery-electric_final_charge_mwh
66 | 0 50.0 2.000000 2.000000 1.8
67 | 1 100.0 0.222222 0.222222 2.0
68 | 2 150.0 -2.000000 -2.000000 0.0
69 | ```
70 |
71 | ## With Network Charge
72 |
73 | ### Asset and Interval Data
74 |
75 | By default, `energypylinear` uses interval data like `electricity_prices` or `electricity_carbon_intensities`. This interval data is supplied when initializing an asset or site.
76 |
77 | For network charges, we will make use of the ability to supply custom interval data. Any extra keyword arguments supplied to an asset or site will be attempted to be parsed as interval data.
78 |
79 | Below we setup a battery with both electricity prices and a network charge:
80 |
81 |
82 | ```python
83 | import energypylinear as epl
84 |
85 | assert electricity_prices == [50, 100, 150]
86 | network_charge = [0, 100, 0]
87 | asset = epl.Battery(electricity_prices=electricity_prices, network_charge=network_charge)
88 | ```
89 |
90 | ### Objective Function
91 |
92 | By default, `energypylinear` has two built-in objective functions - `price` and `carbon`.
93 |
94 | In order to optimize for a network charge, we need to supply a custom objective function. This function will be passed to the `optimize` method of an asset or site.
95 |
96 | Below we optimize our battery with a custom objective function:
97 |
98 |
99 | ```python
100 | network_charge = asset.optimize(
101 | {
102 | "terms": [
103 | {
104 | "asset_type": "site",
105 | "variable": "import_power_mwh",
106 | "interval_data": "electricity_prices",
107 | },
108 | {
109 | "asset_type": "site",
110 | "variable": "export_power_mwh",
111 | "interval_data": "electricity_prices",
112 | "coefficient": -1,
113 | },
114 | {
115 | "asset_type": "site",
116 | "variable": "import_power_mwh",
117 | "interval_data": "network_charge",
118 | "coefficient": 1,
119 | },
120 | ]
121 | },
122 | verbose=3
123 | )
124 | ```
125 |
126 | ### Results
127 |
128 |
129 | ```python
130 | results = network_charge.results
131 | results["battery-net_charge_mwh"] = (
132 | results["battery-electric_charge_mwh"] - results["battery-electric_discharge_mwh"]
133 | )
134 | results["site-net_import_mwh"] = (
135 | results["site-import_power_mwh"] - results["site-export_power_mwh"]
136 | )
137 | print(
138 | network_charge.results[
139 | [
140 | "site-electricity_prices",
141 | "site-network_charge",
142 | "site-net_import_mwh",
143 | "battery-net_charge_mwh",
144 | "battery-electric_final_charge_mwh",
145 | ]
146 | ]
147 | )
148 | ```
149 |
150 | We now see that our battery has not charged during the second interval, where we have a high site network charge:
151 |
152 | ```
153 | site-electricity_prices site-network_charge site-net_import_mwh battery-net_charge_mwh battery-electric_final_charge_mwh
154 | 0 50.0 0.0 2.0 2.0 1.8
155 | 1 100.0 100.0 0.0 0.0 1.8
156 | 2 150.0 0.0 -1.8 -1.8 0.0
157 | ```
158 |
--------------------------------------------------------------------------------
/docs/docs/how-to/price-carbon.md:
--------------------------------------------------------------------------------
1 | `energypylinear` can optimize for both price and carbon as optimization objectives.
2 |
3 | This ability comes from two things - an objective function, which can be either for price or carbon, along with accounting of both price and carbon emissions.
4 |
5 | We can dispatch a battery to minimize carbon emissions by passing in `objective='carbon'`:
6 |
7 | ## Setup Interval Data
8 |
9 |
10 | ```python
11 | import energypylinear as epl
12 |
13 | electricity_prices = [100, 50, 200, -100, 0, 200, 100, -100]
14 | electricity_carbon_intensities = [0.1, 0.2, 0.1, 0.15, 0.01, 0.7, 0.5, 0.01]
15 | ```
16 |
17 | ## Optimize for Carbon
18 |
19 |
20 | ```python
21 | asset = epl.Battery(
22 | power_mw=2,
23 | capacity_mwh=4,
24 | efficiency_pct=0.9,
25 | electricity_prices=electricity_prices,
26 | electricity_carbon_intensities=electricity_carbon_intensities,
27 | )
28 | carbon = asset.optimize(objective="carbon", verbose=3)
29 |
30 | carbon_account = epl.get_accounts(carbon.results, verbose=3)
31 | print(f"{carbon_account=}")
32 | ```
33 |
34 | ```
35 | carbon_account=
36 | ```
37 |
38 | ## Optimize for Money
39 |
40 | We can compare these results above with a simulation that optimizes for price, using a `energypylinear.accounting.Account` to compare both simulations.
41 |
42 | Our optimization for price has a high negative cost.
43 |
44 | The optimization for carbon has lower emissions, but at a higher cost:
45 |
46 |
47 | ```python
48 | asset = epl.Battery(
49 | power_mw=2,
50 | capacity_mwh=4,
51 | efficiency_pct=0.9,
52 | electricity_prices=electricity_prices,
53 | electricity_carbon_intensities=electricity_carbon_intensities,
54 | )
55 | price = asset.optimize(
56 | objective="price",
57 | verbose=3
58 | )
59 |
60 | price_account = epl.get_accounts(price.results, verbose=3)
61 | print(f"{price_account=}")
62 | ```
63 |
64 | ```
65 | price_account=
66 | ```
67 |
68 | ## Calculate Variance Between Accounts
69 |
70 |
71 | ```python
72 | variance = price_account - carbon_account
73 | print(f"{variance=}")
74 | print(f"{-variance.cost / variance.emissions:.2f} $/tC")
75 | ```
76 |
77 | ```
78 | variance=
79 | 1467.51 $/tC
80 | ```
81 |
82 | ## Full Example
83 |
84 | ```python
85 | import energypylinear as epl
86 |
87 | electricity_prices = [100, 50, 200, -100, 0, 200, 100, -100]
88 | electricity_carbon_intensities = [0.1, 0.2, 0.1, 0.15, 0.01, 0.7, 0.5, 0.01]
89 | asset = epl.Battery(
90 | power_mw=2,
91 | capacity_mwh=4,
92 | efficiency_pct=0.9,
93 | electricity_prices=electricity_prices,
94 | electricity_carbon_intensities=electricity_carbon_intensities,
95 | )
96 |
97 | # optimize for carbon
98 | carbon = asset.optimize(objective="carbon", verbose=3)
99 | carbon_account = epl.get_accounts(carbon.results, verbose=3)
100 | print(f"{carbon_account=}")
101 |
102 | # optimize for money
103 | price = asset.optimize(
104 | objective="price",
105 | verbose=3
106 | )
107 | price_account = epl.get_accounts(price.results, verbose=3)
108 | print(f"{price_account=}")
109 |
110 | # calculate variance (difference) between accounts
111 | variance = price_account - carbon_account
112 | print(f"{variance=}")
113 | print(f"{-variance.cost / variance.emissions:.2f} $/tC")
114 | ```
115 |
--------------------------------------------------------------------------------
/docs/docs/index.md:
--------------------------------------------------------------------------------
1 | # energy-py-linear
2 |
3 | {!../../README.md!lines=11-99}
4 |
--------------------------------------------------------------------------------
/docs/docs/performance.md:
--------------------------------------------------------------------------------
1 | # Performance
2 |
3 | This page is in development - you shouldn't be here!
4 |
5 | ## Battery
6 |
7 | 
8 |
9 | ## EVs
10 |
11 | 
12 |
--------------------------------------------------------------------------------
/docs/docs/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ADGEfficiency/energy-py-linear/85af22bc3d7631b889c9dc207bfbbf01516ae632/docs/docs/static/favicon.ico
--------------------------------------------------------------------------------
/docs/docs/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ADGEfficiency/energy-py-linear/85af22bc3d7631b889c9dc207bfbbf01516ae632/docs/docs/static/logo.png
--------------------------------------------------------------------------------
/docs/generate-plots.py:
--------------------------------------------------------------------------------
1 | """Generate plots for the documentation."""
2 | import collections
3 | import statistics
4 | import time
5 | import timeit
6 |
7 | import matplotlib.pyplot as plt
8 | import numpy as np
9 | from rich import print
10 |
11 | import energypylinear as epl
12 | from energypylinear.flags import Flags
13 |
14 | # from energypylinear.logger import logger
15 |
16 |
17 | def test_battery_performance() -> None:
18 | """Test the Battery run time performance."""
19 | idx_lengths = [
20 | 6,
21 | # one day 60 min freq
22 | 24,
23 | # one week 60 min freq
24 | 168,
25 | # one week 15 min freq
26 | 672,
27 | # two weeks
28 | 1344,
29 | ]
30 | num_trials = 15
31 |
32 | run_times = collections.defaultdict(list)
33 | for idx_length in idx_lengths:
34 | trial_times = collections.defaultdict(list)
35 |
36 | for n_trial in range(num_trials):
37 | print(f"idx_length: {idx_length} trial {n_trial}")
38 | st = time.perf_counter()
39 |
40 | ds = {"electricity_prices": np.random.uniform(-1000, 1000, idx_length)}
41 | asset = epl.Battery(
42 | power_mw=2,
43 | capacity_mwh=4,
44 | efficiency_pct=0.9,
45 | electricity_prices=ds["electricity_prices"],
46 | )
47 |
48 | asset.optimize(
49 | verbose=False,
50 | flags=Flags(
51 | allow_evs_discharge=True,
52 | fail_on_spill_asset_use=True,
53 | allow_infeasible=False,
54 | ),
55 | )
56 |
57 | trial_times["time"].append(time.perf_counter() - st)
58 |
59 | run_times["time"].append(
60 | {
61 | "mean": statistics.mean(trial_times["time"]),
62 | "std": statistics.stdev(trial_times["time"]),
63 | "idx_length": idx_length,
64 | }
65 | )
66 | print(run_times["time"])
67 |
68 | fig, axes = plt.subplots(nrows=1, sharex=True)
69 | print("[red]final run times:[/]")
70 | print(run_times)
71 |
72 | axes.plot(
73 | [p["idx_length"] for p in run_times["time"]],
74 | [p["mean"] for p in run_times["time"]],
75 | marker="o",
76 | )
77 | axes.set_title(asset.__repr__())
78 | axes.set_ylabel("Run Time (seconds)")
79 | axes.legend()
80 | axes.grid(True)
81 | plt.xlabel("Index Length")
82 | plt.tight_layout()
83 | fig.savefig("./docs/docs/static/battery-performance.png")
84 |
85 |
86 | def test_evs_performance() -> None:
87 | """Test the Battery run time performance."""
88 | idx_lengths = [
89 | 6,
90 | # one day 60 min freq
91 | 24,
92 | # one week 60 min freq
93 | 168,
94 | # one week 15 min freq
95 | 672,
96 | # two weeks 15 min freq
97 | 1344,
98 | ]
99 | data = collections.defaultdict(list)
100 | for flag in [False, True]:
101 | for idx_length in idx_lengths:
102 | start_time = timeit.default_timer()
103 |
104 | ds = epl.data_generation.generate_random_ev_input_data(
105 | idx_length,
106 | n_chargers=2,
107 | charge_length=10,
108 | n_charge_events=24,
109 | prices_mu=500,
110 | prices_std=10,
111 | )
112 | asset = epl.EVs(
113 | charger_turndown=0.2,
114 | **ds,
115 | )
116 | asset.optimize(
117 | verbose=False,
118 | flags=Flags(
119 | allow_evs_discharge=True,
120 | fail_on_spill_asset_use=False,
121 | allow_infeasible=False,
122 | limit_charge_variables_to_valid_events=flag,
123 | ),
124 | )
125 |
126 | elapsed = timeit.default_timer() - start_time
127 | data["pkg"].append(
128 | {"idx_length": idx_length, "time": elapsed, "flag": flag}
129 | )
130 | # logger.info(
131 | # "test_evs_performance",
132 | # idx_length=idx_length,
133 | # elapsed=elapsed,
134 | # flag=flag,
135 | # )
136 |
137 | # plt.figure()
138 | fig, axes = plt.subplots(nrows=1, sharex=True)
139 | for flag in [False, True]:
140 | subset: list = [p for p in data["pkg"] if p["flag"] == flag]
141 | plt.plot(
142 | [p["idx_length"] for p in subset],
143 | [p["time"] for p in subset],
144 | "o-",
145 | label=f"limit_charge_variables_to_valid_events: {flag}",
146 | )
147 | plt.xlabel("Index Length")
148 | plt.ylabel("Run Time (seconds)")
149 | plt.legend()
150 | plt.title(asset.__repr__())
151 | plt.grid(True)
152 | plt.savefig("./docs/docs/static/evs-performance.png")
153 |
154 |
155 | if __name__ == "__main__":
156 | test_battery_performance()
157 | test_evs_performance()
158 |
--------------------------------------------------------------------------------
/docs/mkdocs.yml:
--------------------------------------------------------------------------------
1 | ---
2 | site_name: energy-py-linear
3 | site_url: https://energypylinear.adgefficiency.com
4 | repo_url: https://github.com/ADGEfficiency/energy-py-linear
5 | repo_name: energy-py-linear
6 | theme:
7 | name: material
8 | logo: static/logo.png
9 | favicon: static/favicon.ico
10 | features:
11 | - navigation.tabs.sticky
12 | - navigation.sections
13 | - navigation.expand
14 | - navigation.path
15 | - navigation.top
16 | - search.suggest
17 | - search.highlight
18 | - content.code.copy
19 | icon:
20 | repo: fontawesome/brands/github
21 | palette:
22 | - scheme: default
23 | primary: light blue
24 | toggle:
25 | icon: material/lightbulb-outline
26 | name: Switch to dark mode
27 | - scheme: slate
28 | primary: cyan
29 | toggle:
30 | icon: material/lightbulb
31 | name: Switch to light mode
32 | plugins:
33 | - material-plausible
34 | - search
35 | - social
36 | - mike:
37 | alias_type: symlink
38 | redirect_template:
39 | deploy_prefix: ''
40 | canonical_version:
41 | version_selector: true
42 | css_dir: css
43 | javascript_dir: js
44 | markdown_extensions:
45 | - markdown_include.include:
46 | base_path: docs
47 | - pymdownx.highlight:
48 | anchor_linenums: true
49 | - pymdownx.superfences
50 | - pymdownx.snippets:
51 | base_path: [., ..]
52 | - attr_list
53 | - md_in_html
54 | - admonition
55 | - pymdownx.details
56 | nav:
57 | - Getting Started: index.md
58 | - Assets:
59 | - Battery: assets/battery.md
60 | - Combined Heat & Power: assets/chp.md
61 | - Electric Vehicles: assets/evs.md
62 | - Heat Pump: assets/heat-pump.md
63 | - Renewable Generator: assets/renewable-generator.md
64 | - Use Cases:
65 | - Multiple Assets: how-to/dispatch-site.md
66 | - Carbon: how-to/price-carbon.md
67 | - Forecast: how-to/dispatch-forecast.md
68 | - Battery Cycles: how-to/battery-cycles.md
69 | - Network Charges: how-to/network-charges.md
70 | >>>>>>> 8a21d117db6b142efd9f1d9f0dd4e87d5a7dae35
71 | - Customization:
72 | - Constraints: how-to/custom-constraints.md
73 | - Objective Functions: how-to/custom-objectives.md
74 | - Interval Data: how-to/custom-interval-data.md
75 | - Changelog: changelog.md
76 | # - Performance: performance.md
77 | # - Measure Forecast Quality: index.md
78 |
79 | # - Results: reference/results.md
80 | # - Asset API: reference/asset-api.md
81 | # - Site API: reference/site-api.md
82 | # - Accounting API: index.md
83 |
84 | # - Tutorials:
85 | # - Optimizing a Generator with the Asset API: index.md
86 | # - Optimizing a Battery & Electric Vehicle Fleet with the Site API: index.md
87 | # - Optimizing for Carbon: index.md
88 |
89 | # - Explanation:
90 | # - Blocks: index.md
91 | # - Spills: index.md
92 | # - Valves: index.md
93 | extra:
94 | version:
95 | provider: mike
96 | analytics:
97 | provider: plausible
98 | domain: energypylinear.adgefficiency.com
99 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | mkdocs-material==9.5.25
2 | mkdocstrings[python]
3 | markdown-include
4 | phmdoctest
5 | pytest
6 | pillow
7 | cairosvg
8 | mike==1.1.2
9 | material-plausible-plugin
10 | pymdown-extensions
11 |
--------------------------------------------------------------------------------
/energypylinear/__init__.py:
--------------------------------------------------------------------------------
1 | """A library for mixed-integer linear optimization of energy assets."""
2 | # isort: skip_file
3 |
4 | from pulp import LpVariable
5 |
6 | from energypylinear.flags import Flags
7 | from energypylinear.optimizer import Optimizer, OptimizerConfig
8 | from energypylinear import assets, data_generation, plot
9 | from energypylinear.accounting import get_accounts
10 | from energypylinear.assets.asset import Asset, OptimizableAsset
11 | from energypylinear.assets.battery import Battery
12 | from energypylinear.assets.boiler import Boiler
13 | from energypylinear.assets.chp import CHP
14 | from energypylinear.assets.evs import EVs
15 | from energypylinear.assets.heat_pump import HeatPump
16 | from energypylinear.assets.renewable_generator import RenewableGenerator
17 | from energypylinear.assets.site import Site, SiteIntervalData
18 | from energypylinear.assets.spill import Spill
19 | from energypylinear.assets.valve import Valve
20 | from energypylinear.constraints import Constraint, ConstraintTerm
21 | from energypylinear.freq import Freq
22 | from energypylinear.interval_data import IntervalVars
23 | from energypylinear.objectives import CustomObjectiveFunction, Term, get_objective
24 | from energypylinear.results.checks import check_results
25 | from energypylinear.results.extract import SimulationResult, extract_results
26 |
27 | __all__ = [
28 | "Asset",
29 | "Battery",
30 | "Boiler",
31 | "Constraint",
32 | "ConstraintTerm",
33 | "CustomObjectiveFunction",
34 | "OptimizableAsset",
35 | "Term",
36 | "get_objective",
37 | "RenewableGenerator",
38 | "assets",
39 | "data_generation",
40 | "plot",
41 | "CHP",
42 | "EVs",
43 | "Flags",
44 | "Freq",
45 | "HeatPump",
46 | "IntervalVars",
47 | "LpVariable",
48 | "Optimizer",
49 | "OptimizerConfig",
50 | "SimulationResult",
51 | "SiteIntervalData",
52 | "Site",
53 | "Spill",
54 | "Valve",
55 | "check_results",
56 | "extract_results",
57 | "get_accounts",
58 | ]
59 |
--------------------------------------------------------------------------------
/energypylinear/accounting/__init__.py:
--------------------------------------------------------------------------------
1 | """Account for the use, cost and carbon emissions of electricity and gas."""
2 | from energypylinear.accounting.accounting import get_accounts
3 |
4 | __all__ = ["get_accounts"]
5 |
--------------------------------------------------------------------------------
/energypylinear/assets/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ADGEfficiency/energy-py-linear/85af22bc3d7631b889c9dc207bfbbf01516ae632/energypylinear/assets/__init__.py
--------------------------------------------------------------------------------
/energypylinear/assets/asset.py:
--------------------------------------------------------------------------------
1 | """Contains AssetOneInterval - used as the base for all single interval energy assets data samples."""
2 | import abc
3 | import typing
4 |
5 | import pulp
6 | import pydantic
7 |
8 | import energypylinear as epl
9 |
10 |
11 | class Asset(abc.ABC):
12 | """Abstract Base Class for an Asset."""
13 |
14 | site: "epl.Site"
15 |
16 | @abc.abstractmethod
17 | def __init__(self) -> None:
18 | """Initializes the asset."""
19 | pass
20 |
21 | @abc.abstractmethod
22 | def __repr__(self) -> str:
23 | """A string representation of self."""
24 | pass
25 |
26 | @abc.abstractmethod
27 | def one_interval(
28 | self, optimizer: "epl.Optimizer", i: int, freq: "epl.Freq", flags: "epl.Flags"
29 | ) -> typing.Any:
30 | """Generate linear program data for one interval."""
31 | pass
32 |
33 | @abc.abstractmethod
34 | def constrain_within_interval(
35 | self,
36 | optimizer: "epl.Optimizer",
37 | ivars: "epl.IntervalVars",
38 | i: int,
39 | freq: "epl.Freq",
40 | flags: "epl.Flags",
41 | ) -> None:
42 | """Constrain asset within an interval."""
43 | pass
44 |
45 | @abc.abstractmethod
46 | def constrain_after_intervals(
47 | self, optimizer: "epl.Optimizer", ivars: "epl.IntervalVars"
48 | ) -> None:
49 | """Constrain asset after all intervals."""
50 | pass
51 |
52 |
53 | class OptimizableAsset(Asset):
54 | """Abstract Base Class of an Optimizable Asset."""
55 |
56 | @abc.abstractmethod
57 | def optimize(
58 | self,
59 | objective: "str | dict | epl.CustomObjectiveFunction" = "price",
60 | verbose: int | bool = 2,
61 | flags: "epl.Flags" = epl.Flags(),
62 | optimizer_config: "epl.OptimizerConfig | dict" = epl.OptimizerConfig(),
63 | ) -> "epl.SimulationResult":
64 | """Optimize sites dispatch using a mixed-integer linear program."""
65 | pass
66 |
67 |
68 | class AssetOneInterval(pydantic.BaseModel):
69 | """Generic energy asset that contains data for a single interval.
70 |
71 | Brought to you by the energy balance:
72 | input - output = accumulation
73 |
74 | Defines the quantities we care about in our energy model for one timestep:
75 | - electricity,
76 | - high temperature heat,
77 | - low temperature heat,
78 | - charge & discharge of electricity,
79 | - gas consumption.
80 |
81 | These quantities are considered as both generation and consumption (load).
82 |
83 | Charge and discharge are handled as explicit accumulation terms.
84 | """
85 |
86 | cfg: typing.Any = None
87 |
88 | electric_generation_mwh: pulp.LpVariable | float = 0
89 | high_temperature_generation_mwh: pulp.LpVariable | float = 0
90 | low_temperature_generation_mwh: pulp.LpVariable | float = 0
91 | electric_load_mwh: pulp.LpVariable | float = 0
92 | high_temperature_load_mwh: pulp.LpVariable | float = 0
93 | low_temperature_load_mwh: pulp.LpVariable | float = 0
94 | electric_charge_mwh: pulp.LpVariable | float = 0
95 | electric_discharge_mwh: pulp.LpVariable | float = 0
96 | gas_consumption_mwh: pulp.LpVariable | float = 0
97 |
98 | binary: pulp.LpVariable | int = 0
99 | model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
100 |
--------------------------------------------------------------------------------
/energypylinear/assets/boiler.py:
--------------------------------------------------------------------------------
1 | """Boiler asset for optimizing dispatch of gas fired boilers"""
2 |
3 | import pulp
4 | import pydantic
5 |
6 | import energypylinear as epl
7 | from energypylinear.assets.asset import AssetOneInterval
8 | from energypylinear.defaults import defaults
9 |
10 |
11 | class BoilerConfig(pydantic.BaseModel):
12 | """Boiler configuration."""
13 |
14 | name: str
15 | high_temperature_generation_max_mw: float = 0
16 | high_temperature_generation_min_mw: float = 0
17 | high_temperature_efficiency_pct: float = pydantic.Field(gt=0.0, default=0.8, le=1.0)
18 |
19 | @pydantic.field_validator("name")
20 | @classmethod
21 | def check_name(cls, name: str) -> str:
22 | """Ensure we can identify this asset correctly."""
23 | assert "boiler" in name
24 | return name
25 |
26 |
27 | class BoilerOneInterval(AssetOneInterval):
28 | """Boiler data for a single interval."""
29 |
30 | high_temperature_generation_mwh: pulp.LpVariable
31 | gas_consumption_mwh: pulp.LpVariable
32 | binary: pulp.LpVariable
33 | cfg: BoilerConfig
34 |
35 |
36 | class Boiler(epl.Asset):
37 | """Boiler asset - generates high temperature heat from natural gas."""
38 |
39 | def __init__(
40 | self,
41 | name: str = "boiler",
42 | high_temperature_generation_max_mw: float = defaults.boiler_high_temperature_generation_max_mw,
43 | high_temperature_generation_min_mw: float = 0,
44 | high_temperature_efficiency_pct: float = defaults.boiler_efficiency_pct,
45 | ):
46 | """Initialize the asset model."""
47 | self.cfg = BoilerConfig(
48 | name=name,
49 | high_temperature_generation_max_mw=high_temperature_generation_max_mw,
50 | high_temperature_generation_min_mw=high_temperature_generation_min_mw,
51 | high_temperature_efficiency_pct=high_temperature_efficiency_pct,
52 | )
53 |
54 | def __repr__(self) -> str:
55 | """A string representation of self."""
56 | return f""
57 |
58 | def one_interval(
59 | self, optimizer: "epl.Optimizer", i: int, freq: "epl.Freq", flags: "epl.Flags"
60 | ) -> BoilerOneInterval:
61 | """Create asset data for a single interval."""
62 | return BoilerOneInterval(
63 | high_temperature_generation_mwh=optimizer.continuous(
64 | f"{self.cfg.name}-high_temperature_generation_mwh-{i}",
65 | low=freq.mw_to_mwh(self.cfg.high_temperature_generation_min_mw),
66 | up=freq.mw_to_mwh(self.cfg.high_temperature_generation_max_mw),
67 | ),
68 | binary=optimizer.binary(
69 | f"{self.cfg.name}-binary_mwh-{i}",
70 | ),
71 | gas_consumption_mwh=optimizer.continuous(
72 | f"{self.cfg.name}-gas_consumption_mwh-{i}"
73 | ),
74 | cfg=self.cfg,
75 | )
76 |
77 | def constrain_within_interval(
78 | self,
79 | optimizer: "epl.Optimizer",
80 | ivars: "epl.IntervalVars",
81 | i: int,
82 | freq: "epl.Freq",
83 | flags: "epl.Flags",
84 | ) -> None:
85 | """Constrain boiler for generation of high temperature heat."""
86 | boiler = ivars.filter_objective_variables(
87 | instance_type=BoilerOneInterval, i=-1, asset_name=self.cfg.name
88 | )[0]
89 | assert isinstance(boiler, BoilerOneInterval)
90 | optimizer.constrain(
91 | boiler.gas_consumption_mwh
92 | == boiler.high_temperature_generation_mwh
93 | * (1 / boiler.cfg.high_temperature_efficiency_pct)
94 | )
95 | optimizer.constrain_max(
96 | boiler.high_temperature_generation_mwh,
97 | boiler.binary,
98 | freq.mw_to_mwh(boiler.cfg.high_temperature_generation_max_mw),
99 | )
100 | optimizer.constrain_min(
101 | boiler.high_temperature_generation_mwh,
102 | boiler.binary,
103 | freq.mw_to_mwh(boiler.cfg.high_temperature_generation_min_mw),
104 | )
105 |
106 | def constrain_after_intervals(
107 | self, optimizer: "epl.Optimizer", ivars: "epl.IntervalVars"
108 | ) -> None:
109 | """Constrain the asset after all intervals."""
110 | return
111 |
--------------------------------------------------------------------------------
/energypylinear/assets/chp.py:
--------------------------------------------------------------------------------
1 | """Asset for optimizing combined heat and power (CHP) generators."""
2 | import pathlib
3 | import typing
4 |
5 | import numpy as np
6 | import pulp
7 | import pydantic
8 |
9 | import energypylinear as epl
10 | from energypylinear.assets.asset import AssetOneInterval
11 | from energypylinear.defaults import defaults
12 | from energypylinear.flags import Flags
13 | from energypylinear.freq import Freq
14 | from energypylinear.optimizer import Optimizer
15 |
16 |
17 | class CHPConfig(pydantic.BaseModel):
18 | """CHP configuration."""
19 |
20 | name: str
21 | electric_power_max_mw: float = 0
22 | electric_power_min_mw: float = 0
23 |
24 | electric_efficiency_pct: float = 0
25 | high_temperature_efficiency_pct: float = 0
26 | low_temperature_efficiency_pct: float = 0
27 |
28 | freq_mins: int
29 |
30 | @pydantic.field_validator("name")
31 | @classmethod
32 | def check_name(cls, name: str) -> str:
33 | """Ensure we can identify this asset correctly."""
34 | assert "chp" in name
35 | return name
36 |
37 |
38 | class CHPOneInterval(AssetOneInterval):
39 | """CHP generator data for a single interval."""
40 |
41 | cfg: CHPConfig
42 |
43 | binary: pulp.LpVariable
44 | electric_generation_mwh: pulp.LpVariable
45 | gas_consumption_mwh: pulp.LpVariable
46 | high_temperature_generation_mwh: pulp.LpVariable
47 | low_temperature_generation_mwh: pulp.LpVariable
48 |
49 |
50 | class CHP(epl.OptimizableAsset):
51 | """
52 | CHP asset - handles optimization and plotting of results over many intervals.
53 |
54 | A CHP (combined heat and power) generator generates electricity, high temperature
55 | and low temperature heat from natural gas.
56 |
57 | This asset can be used to model gas turbines, gas engines or open cycle generators
58 | like diesel generators.
59 |
60 | Make sure to get your efficiencies and gas prices on the same basis (HHV or LHV).
61 | """
62 |
63 | def __init__(
64 | self,
65 | electric_efficiency_pct: float = 0.0,
66 | electric_power_max_mw: float = 0.0,
67 | electric_power_min_mw: float = 0.0,
68 | high_temperature_efficiency_pct: float = 0.0,
69 | low_temperature_efficiency_pct: float = 0.0,
70 | electricity_prices: np.ndarray | list[float] | float | None = None,
71 | export_electricity_prices: np.ndarray | list[float] | float | None = None,
72 | electricity_carbon_intensities: np.ndarray | list[float] | float | None = None,
73 | gas_prices: np.ndarray | list[float] | float | None = None,
74 | high_temperature_load_mwh: np.ndarray | list[float] | float | None = None,
75 | low_temperature_load_mwh: np.ndarray | list[float] | float | None = None,
76 | low_temperature_generation_mwh: np.ndarray | list[float] | float | None = None,
77 | name: str = "chp",
78 | freq_mins: int = defaults.freq_mins,
79 | constraints: "list[epl.Constraint] | list[dict] | None" = None,
80 | include_spill: bool = False,
81 | **kwargs: typing.Any,
82 | ):
83 | """
84 | Initialize a Combined Heat and Power (CHP) asset.
85 |
86 | Args:
87 | electric_power_max_mw: Maximum electric power output of the generator in mega-watts.
88 | electric_power_min_mw: Minimum electric power output of the generator in mega-watts.
89 | electric_efficiency_pct: Electric efficiency of the generator, measured in percentage.
90 | high_temperature_efficiency_pct: High temperature efficiency of the generator, measured in percentage.
91 | low_temperature_efficiency_pct: The low temperature efficiency of the generator, measured in percentage.
92 | electricity_prices: Price of electricity in each interval.
93 | export_electricity_prices: The price of export electricity in each interval.
94 | electricity_carbon_intensities: carbon intensity of electricity in each interval.
95 | gas_prices: Price of natural gas, used in CHP and boilers in each interval.
96 | high_temperature_load_mwh: High temperature load of the site.
97 | low_temperature_load_mwh: Low temperature load of the site.
98 | low_temperature_generation_mwh: Avaialable low temperature generation of the site.
99 | name: The asset name.
100 | freq_mins: length of the simulation intervals in minutes.
101 | constraints: Additional custom constraints to apply to the linear program.
102 | include_spill: Whether to include a spill asset in the site.
103 | kwargs: Extra keyword arguments attempted to be used as custom interval data.
104 | """
105 | self.cfg = CHPConfig(
106 | name=name,
107 | electric_power_min_mw=electric_power_min_mw,
108 | electric_power_max_mw=electric_power_max_mw,
109 | electric_efficiency_pct=electric_efficiency_pct,
110 | high_temperature_efficiency_pct=high_temperature_efficiency_pct,
111 | low_temperature_efficiency_pct=low_temperature_efficiency_pct,
112 | freq_mins=freq_mins,
113 | )
114 |
115 | if electricity_prices is not None or electricity_carbon_intensities is not None:
116 | assets: list[epl.Asset] = [self, epl.Valve(), epl.Boiler()]
117 | if include_spill:
118 | assets.append(epl.Spill())
119 | self.site = epl.Site(
120 | assets=assets,
121 | electricity_prices=electricity_prices,
122 | export_electricity_prices=export_electricity_prices,
123 | electricity_carbon_intensities=electricity_carbon_intensities,
124 | gas_prices=gas_prices,
125 | high_temperature_load_mwh=high_temperature_load_mwh,
126 | low_temperature_load_mwh=low_temperature_load_mwh,
127 | low_temperature_generation_mwh=low_temperature_generation_mwh,
128 | freq_mins=self.cfg.freq_mins,
129 | constraints=constraints,
130 | **kwargs,
131 | )
132 |
133 | def __repr__(self) -> str:
134 | """A string representation of self."""
135 | return f""
136 |
137 | def one_interval(
138 | self, optimizer: Optimizer, i: int, freq: Freq, flags: Flags = Flags()
139 | ) -> CHPOneInterval:
140 | """Generate linear program data for one interval."""
141 | return CHPOneInterval(
142 | electric_generation_mwh=optimizer.continuous(
143 | f"{self.cfg.name}-electric_generation_mwh-{i}",
144 | low=0,
145 | up=freq.mw_to_mwh(self.cfg.electric_power_max_mw),
146 | ),
147 | binary=optimizer.binary(
148 | f"{self.cfg.name}-binary_mwh-{i}",
149 | ),
150 | gas_consumption_mwh=optimizer.continuous(
151 | f"{self.cfg.name}-gas_consumption_mwh-{i}"
152 | ),
153 | high_temperature_generation_mwh=optimizer.continuous(
154 | f"{self.cfg.name}-high_temperature_generation_mwh-{i}",
155 | low=0,
156 | ),
157 | low_temperature_generation_mwh=optimizer.continuous(
158 | f"{self.cfg.name}-low_temperature_generation_mwh-{i}",
159 | low=0,
160 | ),
161 | cfg=self.cfg,
162 | )
163 |
164 | def constrain_within_interval(
165 | self,
166 | optimizer: Optimizer,
167 | ivars: "epl.interval_data.IntervalVars",
168 | i: int,
169 | freq: Freq,
170 | flags: Flags,
171 | ) -> None:
172 | """Constrain generator upper and lower bounds for generating electricity, high
173 | and low temperature heat within a single interval."""
174 | chp = ivars.filter_objective_variables(
175 | instance_type=CHPOneInterval, i=i, asset_name=self.cfg.name
176 | )[0]
177 | assert isinstance(chp, CHPOneInterval)
178 | if chp.cfg.electric_efficiency_pct > 0:
179 | optimizer.constrain(
180 | chp.gas_consumption_mwh
181 | == chp.electric_generation_mwh * (1 / chp.cfg.electric_efficiency_pct)
182 | )
183 | optimizer.constrain(
184 | chp.high_temperature_generation_mwh
185 | == chp.gas_consumption_mwh * chp.cfg.high_temperature_efficiency_pct
186 | )
187 | optimizer.constrain(
188 | chp.low_temperature_generation_mwh
189 | == chp.gas_consumption_mwh * chp.cfg.low_temperature_efficiency_pct
190 | )
191 | optimizer.constrain_max(
192 | chp.electric_generation_mwh,
193 | chp.binary,
194 | freq.mw_to_mwh(chp.cfg.electric_power_max_mw),
195 | )
196 | optimizer.constrain_min(
197 | chp.electric_generation_mwh,
198 | chp.binary,
199 | freq.mw_to_mwh(chp.cfg.electric_power_min_mw),
200 | )
201 |
202 | def constrain_after_intervals(
203 | self, *args: typing.Tuple[typing.Any], **kwargs: typing.Any
204 | ) -> None:
205 | """Constrain the asset after all intervals."""
206 | return
207 |
208 | def optimize(
209 | self,
210 | objective: "str | dict | epl.objectives.CustomObjectiveFunction" = "price",
211 | verbose: int | bool = 2,
212 | flags: Flags = Flags(),
213 | optimizer_config: "epl.OptimizerConfig | dict" = epl.optimizer.OptimizerConfig(),
214 | ) -> "epl.SimulationResult":
215 | """
216 | Optimize the CHP generator's dispatch using a mixed-integer linear program.
217 |
218 | Args:
219 | objective: the optimization objective - either "price" or "carbon".
220 | verbose: level of printing.
221 | flags: boolean flags to change simulation and results behaviour.
222 | optimizer_config: configuration options for the optimizer.
223 |
224 | Returns:
225 | epl.results.SimulationResult
226 | """
227 | return self.site.optimize(
228 | objective=objective,
229 | flags=flags,
230 | verbose=verbose,
231 | optimizer_config=optimizer_config,
232 | )
233 |
234 | def plot(self, results: "epl.SimulationResult", path: pathlib.Path | str) -> None:
235 | """Plot simulation results."""
236 | return epl.plot.plot_chp(results, pathlib.Path(path))
237 |
--------------------------------------------------------------------------------
/energypylinear/assets/heat_pump.py:
--------------------------------------------------------------------------------
1 | """Heat Pump asset."""
2 | import pathlib
3 | import typing
4 |
5 | import numpy as np
6 | import pulp
7 | import pydantic
8 |
9 | import energypylinear as epl
10 | from energypylinear.assets.asset import AssetOneInterval
11 | from energypylinear.defaults import defaults
12 | from energypylinear.flags import Flags
13 |
14 |
15 | class HeatPumpConfig(pydantic.BaseModel):
16 | """Heat Pump asset configuration."""
17 |
18 | cop: float
19 | electric_power_mw: float
20 | freq_mins: int
21 | include_valve: bool
22 | name: str
23 |
24 | @pydantic.field_validator("cop", mode="after")
25 | @classmethod
26 | def validate_cop(cls, value: float) -> float:
27 | """Check COP is greater than 1.0.
28 |
29 | Heat balance doesn't make sense with a COP less than 1.0.
30 |
31 | The HT heat output is the sum of the LT heat and electricity input.
32 |
33 | Introducing losses to the heat pump would allow COPs less than 1.0.
34 | """
35 | if value < 1:
36 | raise ValueError("COP must be 1 or above")
37 | return value
38 |
39 |
40 | class HeatPumpOneInterval(AssetOneInterval):
41 | """Heat pump asset data for a single interval."""
42 |
43 | cfg: HeatPumpConfig
44 | electric_load_mwh: pulp.LpVariable
45 | electric_load_binary: pulp.LpVariable
46 | low_temperature_load_mwh: pulp.LpVariable
47 | high_temperature_generation_mwh: pulp.LpVariable
48 |
49 |
50 | class HeatPump(epl.OptimizableAsset):
51 | """A heat pump generates high temperature heat from low temperature heat and electricity."""
52 |
53 | def __init__(
54 | self,
55 | cop: float = 3.0,
56 | electric_power_mw: float = 1.0,
57 | electricity_prices: np.ndarray | list[float] | float | None = None,
58 | export_electricity_prices: np.ndarray | list[float] | float | None = None,
59 | electricity_carbon_intensities: np.ndarray | list[float] | float | None = None,
60 | gas_prices: np.ndarray | list[float] | float | None = None,
61 | high_temperature_load_mwh: np.ndarray | list[float] | float | None = None,
62 | low_temperature_load_mwh: np.ndarray | list[float] | float | None = None,
63 | low_temperature_generation_mwh: np.ndarray | list[float] | float | None = None,
64 | name: str = "heat-pump",
65 | freq_mins: int = defaults.freq_mins,
66 | constraints: "list[epl.Constraint] | list[dict] | None" = None,
67 | include_spill: bool = False,
68 | include_valve: bool = True,
69 | **kwargs: typing.Any,
70 | ):
71 | """Initializes the asset.
72 |
73 | Args:
74 | electric_power_mw: the maximum power input of the heat pump.
75 | cop: the coefficient of performance of the heat pump.
76 | The ratio of high temperature heat output over input electricity.
77 | name: the asset name.
78 | freq_mins: Size of an interval in minutes.
79 | electricity_prices: Price of electricity in each interval.
80 | gas_prices: Price of natural gas, used in CHP and boilers.
81 | electricity_carbon_intensities: Carbon intensity of electricity.
82 | high_temperature_load_mwh: High temperature load of the site.
83 | low_temperature_load_mwh: Low temperature load of the site.
84 | low_temperature_generation_mwh: low temperature heat generated by the site.
85 | name: The asset name.
86 | freq_mins: length of the simulation intervals in minutes.
87 | constraints: Additional custom constraints to apply to the linear program.
88 | include_valve: Whether to allow heat to flow from high to low temperature.
89 | include_spill: Whether to include a spill asset in the site.
90 | kwargs: Extra keyword arguments attempted to be used as custom interval data.
91 | """
92 | self.cfg = HeatPumpConfig(
93 | cop=cop,
94 | electric_power_mw=electric_power_mw,
95 | freq_mins=freq_mins,
96 | include_valve=include_valve,
97 | name=name,
98 | )
99 |
100 | if electricity_prices is not None or electricity_carbon_intensities is not None:
101 | assets: list[epl.Asset] = [self, epl.Boiler()]
102 |
103 | if include_spill:
104 | assets.append(epl.Spill())
105 | if include_valve:
106 | assets.append(epl.Valve())
107 |
108 | self.site = epl.Site(
109 | assets=assets,
110 | electricity_prices=electricity_prices,
111 | electricity_carbon_intensities=electricity_carbon_intensities,
112 | export_electricity_prices=export_electricity_prices,
113 | gas_prices=gas_prices,
114 | high_temperature_load_mwh=high_temperature_load_mwh,
115 | low_temperature_load_mwh=low_temperature_load_mwh,
116 | low_temperature_generation_mwh=low_temperature_generation_mwh,
117 | freq_mins=self.cfg.freq_mins,
118 | constraints=constraints,
119 | **kwargs,
120 | )
121 |
122 | def __repr__(self) -> str:
123 | """A string representation of self."""
124 | return (
125 | f""
126 | )
127 |
128 | def one_interval(
129 | self, optimizer: "epl.Optimizer", i: int, freq: "epl.Freq", flags: "epl.Flags"
130 | ) -> HeatPumpOneInterval:
131 | """Create asset data for a single interval."""
132 | name = f"i:{i},asset:{self.cfg.name}"
133 | return HeatPumpOneInterval(
134 | cfg=self.cfg,
135 | electric_load_mwh=optimizer.continuous(
136 | f"electric_load_mwh,{name}",
137 | low=0,
138 | up=freq.mw_to_mwh(self.cfg.electric_power_mw),
139 | ),
140 | electric_load_binary=optimizer.binary(
141 | f"electric_load_binary,{name}",
142 | ),
143 | low_temperature_load_mwh=optimizer.continuous(
144 | f"low_temperature_load_mwh,{name}",
145 | low=0,
146 | ),
147 | high_temperature_generation_mwh=optimizer.continuous(
148 | f"high_temperature_generation_mwh,{name}",
149 | low=0,
150 | ),
151 | )
152 |
153 | def constrain_within_interval(
154 | self,
155 | optimizer: "epl.Optimizer",
156 | ivars: "epl.IntervalVars",
157 | i: int,
158 | freq: "epl.Freq",
159 | flags: "epl.Flags",
160 | ) -> None:
161 | """Constrain asset within a single interval."""
162 | heat_pump = ivars.filter_objective_variables(
163 | instance_type=HeatPumpOneInterval, i=i, asset_name=self.cfg.name
164 | )[0]
165 | assert isinstance(heat_pump, HeatPumpOneInterval)
166 | optimizer.constrain_max(
167 | heat_pump.electric_load_mwh,
168 | heat_pump.electric_load_binary,
169 | freq.mw_to_mwh(heat_pump.cfg.electric_power_mw),
170 | )
171 | optimizer.constrain(
172 | heat_pump.high_temperature_generation_mwh
173 | == heat_pump.electric_load_mwh * heat_pump.cfg.cop
174 | )
175 | optimizer.constrain(
176 | heat_pump.low_temperature_load_mwh + heat_pump.electric_load_mwh
177 | == heat_pump.high_temperature_generation_mwh
178 | )
179 |
180 | def constrain_after_intervals(
181 | self,
182 | optimizer: "epl.Optimizer",
183 | ivars: "epl.IntervalVars",
184 | ) -> None:
185 | """Constrain the asset after all intervals."""
186 | pass
187 |
188 | def optimize(
189 | self,
190 | objective: "str | dict | epl.objectives.CustomObjectiveFunction" = "price",
191 | verbose: int | bool = 2,
192 | flags: Flags = Flags(),
193 | optimizer_config: "epl.OptimizerConfig | dict" = epl.optimizer.OptimizerConfig(),
194 | ) -> "epl.SimulationResult":
195 | """Optimize the asset dispatch using a mixed-integer linear program.
196 |
197 | Args:
198 | objective: the optimization objective - either "price" or "carbon".
199 | flags: boolean flags to change simulation and results behaviour.
200 | verbose: level of printing.
201 | optimizer_config: configuration options for the optimizer.
202 |
203 | Returns:
204 | epl.results.SimulationResult
205 | """
206 | return self.site.optimize(
207 | objective=objective,
208 | flags=flags,
209 | verbose=verbose,
210 | optimizer_config=optimizer_config,
211 | )
212 |
213 | def plot(self, results: "epl.SimulationResult", path: pathlib.Path | str) -> None:
214 | """Plot simulation results."""
215 | return epl.plot.plot_heat_pump(
216 | results, pathlib.Path(path), asset_name=self.cfg.name
217 | )
218 |
--------------------------------------------------------------------------------
/energypylinear/assets/spill.py:
--------------------------------------------------------------------------------
1 | """Spill asset for allowing addition electric or thermal generation or consumption.
2 |
3 | This allows infeasible simulations to become feasible. If a spill asset is used, then a warning is raised.
4 | """
5 | import typing
6 |
7 | import pulp
8 | import pydantic
9 |
10 | import energypylinear as epl
11 | from energypylinear.assets.asset import AssetOneInterval
12 |
13 |
14 | class SpillConfig(AssetOneInterval):
15 | """Spill configuration."""
16 |
17 | name: str = "spill"
18 |
19 | @pydantic.field_validator("name")
20 | @classmethod
21 | def check_name(cls, name: str) -> str:
22 | """Ensure we can identify this asset correctly."""
23 | assert "spill" in name
24 | return name
25 |
26 |
27 | class SpillOneInterval(AssetOneInterval):
28 | """Spill asset data for a single interval."""
29 |
30 | cfg: SpillConfig = SpillConfig()
31 | electric_generation_mwh: pulp.LpVariable
32 | electric_load_mwh: pulp.LpVariable
33 | high_temperature_generation_mwh: pulp.LpVariable
34 | low_temperature_load_mwh: pulp.LpVariable
35 |
36 | electric_charge_mwh: float = 0.0
37 | electric_discharge_mwh: float = 0.0
38 |
39 |
40 | class Spill(epl.Asset):
41 | """Spill asset - allows excess or insufficient balances to be filled in."""
42 |
43 | def __init__(self, name: str = "spill"):
44 | """Initializes the asset."""
45 | self.cfg = SpillConfig(name=name)
46 |
47 | def __repr__(self) -> str:
48 | """A string representation of self."""
49 | return ""
50 |
51 | def one_interval(
52 | self,
53 | optimizer: epl.optimizer.Optimizer,
54 | i: int,
55 | freq: epl.freq.Freq,
56 | flags: epl.flags.Flags = epl.flags.Flags(),
57 | ) -> SpillOneInterval:
58 | """Generate linear program data for one interval."""
59 | return SpillOneInterval(
60 | cfg=self.cfg,
61 | electric_generation_mwh=optimizer.continuous(
62 | f"{self.cfg.name}-electric_generation_mwh-{i}", low=0
63 | ),
64 | high_temperature_generation_mwh=optimizer.continuous(
65 | f"{self.cfg.name}-high_temperature_generation_mwh-{i}", low=0
66 | ),
67 | electric_load_mwh=optimizer.continuous(
68 | f"{self.cfg.name}-electric_load_mwh-{i}", low=0
69 | ),
70 | low_temperature_load_mwh=optimizer.continuous(
71 | f"{self.cfg.name}-low_temperature_load_mwh-{i}", low=0
72 | ),
73 | )
74 |
75 | def constrain_within_interval(
76 | self, *args: typing.Any, **kwargs: typing.Any
77 | ) -> None:
78 | """Constrain asset within a single interval"""
79 | return
80 |
81 | def constrain_after_intervals(
82 | self, *args: typing.Any, **kwargs: typing.Any
83 | ) -> None:
84 | """Constrain asset after all intervals."""
85 | return
86 |
--------------------------------------------------------------------------------
/energypylinear/assets/valve.py:
--------------------------------------------------------------------------------
1 | """Valve asset for allowing heat to flow from high to low temperature.
2 |
3 | This allows high temperature heat generated by either gas boilers or
4 | CHP generators to be used for low temperature heat consumption.
5 | """
6 | import pulp
7 | import pydantic
8 |
9 | import energypylinear as epl
10 | from energypylinear.assets.asset import AssetOneInterval
11 |
12 |
13 | class ValveConfig(pydantic.BaseModel):
14 | """Valve configuration."""
15 |
16 | name: str
17 |
18 | @pydantic.field_validator("name")
19 | @classmethod
20 | def check_name(cls, name: str) -> str:
21 | """Ensure we can identify this asset correctly."""
22 | assert "valve" in name
23 | return name
24 |
25 |
26 | class ValveOneInterval(AssetOneInterval):
27 | """Valve asset data for a single interval."""
28 |
29 | cfg: ValveConfig
30 | high_temperature_load_mwh: pulp.LpVariable
31 | low_temperature_generation_mwh: pulp.LpVariable
32 |
33 |
34 | class Valve(epl.Asset):
35 | """Valve asset - allows heat to flow from high to low temperature."""
36 |
37 | def __init__(self, name: str = "valve"):
38 | """Initialize the asset model."""
39 | self.cfg = ValveConfig(name=name)
40 |
41 | def __repr__(self) -> str:
42 | """A string representation of self."""
43 | return ""
44 |
45 | def one_interval(
46 | self, optimizer: "epl.Optimizer", i: int, freq: "epl.Freq", flags: "epl.Flags"
47 | ) -> ValveOneInterval:
48 | """Create asset data for a single interval."""
49 | return ValveOneInterval(
50 | cfg=self.cfg,
51 | high_temperature_load_mwh=optimizer.continuous(
52 | f"{self.cfg.name}-high_temperature_load_mwh-{i}", low=0
53 | ),
54 | low_temperature_generation_mwh=optimizer.continuous(
55 | f"{self.cfg.name}-low_temperature_generation_mwh-{i}", low=0
56 | ),
57 | )
58 |
59 | def constrain_within_interval(
60 | self,
61 | optimizer: "epl.Optimizer",
62 | ivars: "epl.IntervalVars",
63 | i: int,
64 | freq: "epl.Freq",
65 | flags: "epl.Flags",
66 | ) -> None:
67 | """Constrain thermal balance across the valve."""
68 | valve = ivars.filter_objective_variables(
69 | instance_type=ValveOneInterval, i=-1, asset_name=self.cfg.name
70 | )[0]
71 | assert isinstance(valve, ValveOneInterval)
72 | optimizer.constrain(
73 | valve.high_temperature_load_mwh == valve.low_temperature_generation_mwh
74 | )
75 |
76 | def constrain_after_intervals(
77 | self, optimizer: "epl.Optimizer", ivars: "epl.IntervalVars"
78 | ) -> None:
79 | """Constrain the asset after all intervals."""
80 | return
81 |
--------------------------------------------------------------------------------
/energypylinear/data_generation.py:
--------------------------------------------------------------------------------
1 | """Utilities for generating interval data"""
2 | import numpy as np
3 |
4 |
5 | def generate_random_ev_input_data(
6 | idx_length: int,
7 | n_chargers: int,
8 | charge_length: int,
9 | normal_mu: float = 0,
10 | normal_std: float = 1,
11 | uniform_min: float = 0,
12 | uniform_max: float = 10,
13 | n_charge_events: int = 10,
14 | prices_mu: float = 100,
15 | prices_std: float = 20,
16 | seed: int | None = 2,
17 | intensity_mu: float = 0.5,
18 | intensity_std: float = 1.0,
19 | ) -> dict:
20 | """Create interval data for the `epl.EVs` smart electric charging asset."""
21 | np.random.seed(seed)
22 | electricity_prices = np.random.normal(prices_mu, prices_std, idx_length)
23 | electricity_carbon_intensities = np.random.normal(
24 | intensity_mu, intensity_std, idx_length
25 | )
26 |
27 | charger_mws = np.random.randint(10, 100, n_chargers)
28 |
29 | charge_events = np.zeros((n_charge_events, idx_length))
30 | charge_length = min(idx_length - 2, charge_length)
31 |
32 | for step in range(charge_events.shape[0]):
33 | length = int(np.random.randint(1, charge_length))
34 | start = int(np.random.randint(0, charge_events.shape[1] - length - 1))
35 | charge_events[step, start: start + length] = 1 # fmt: skip
36 |
37 | charge_event_mwh = np.random.normal(
38 | normal_mu, normal_std, n_charge_events
39 | ) + np.random.uniform(uniform_min, uniform_max, n_charge_events)
40 | charge_event_mwh = np.clip(charge_event_mwh, a_min=0, a_max=None)
41 |
42 | return {
43 | "electricity_prices": electricity_prices.tolist(),
44 | "electricity_carbon_intensities": electricity_carbon_intensities.tolist(),
45 | "chargers_power_mw": charger_mws,
46 | "charge_events_capacity_mwh": charge_event_mwh,
47 | "charge_events": charge_events,
48 | }
49 |
--------------------------------------------------------------------------------
/energypylinear/debug.py:
--------------------------------------------------------------------------------
1 | """Functions used for interactive debugging."""
2 | import pandas as pd
3 | from rich import print
4 |
5 | from energypylinear.results.checks import (
6 | check_electricity_balance,
7 | check_high_temperature_heat_balance,
8 | check_low_temperature_heat_balance,
9 | )
10 |
11 |
12 | def _debug_column(simulation: pd.DataFrame, col: str) -> None:
13 | """Print a subset of one column for debugging"""
14 | cols = [c for c in simulation.columns if col in c]
15 | subset = simulation[cols]
16 | subset.columns = [c.replace(col, "") for c in subset.columns]
17 | print(col)
18 | print(subset)
19 |
20 |
21 | def debug_simulation(simulation: pd.DataFrame) -> None:
22 | """Debug a simulation result."""
23 | print("[red]DEBUG[/red]")
24 | debug = [
25 | "site-import_power_mwh",
26 | "site-export_power_mwh",
27 | "site-electricity_carbon_intensities",
28 | ]
29 | print(simulation[debug])
30 |
31 | _debug_column(simulation, "electric_charge_mwh")
32 | _debug_column(simulation, "electric_discharge_mwh")
33 | _debug_column(simulation, "initial_soc_mwh")
34 | _debug_column(simulation, "final_soc_mwh")
35 | _debug_column(simulation, "electric_loss_mwh")
36 |
37 |
38 | def debug_balances(simulation: pd.DataFrame) -> None:
39 | """Runs balance checks."""
40 | check_electricity_balance(simulation, verbose=True)
41 | check_high_temperature_heat_balance(simulation, verbose=True)
42 | check_low_temperature_heat_balance(simulation, verbose=True)
43 |
44 |
45 | def debug_asset(
46 | simulation: pd.DataFrame, name: str, verbose: bool = True
47 | ) -> pd.DataFrame:
48 | """Extracts result columns for a single asset."""
49 | cols = [c for c in simulation.columns if name in c]
50 | if verbose:
51 | print(simulation[cols])
52 | return simulation[cols]
53 |
--------------------------------------------------------------------------------
/energypylinear/defaults.py:
--------------------------------------------------------------------------------
1 | """Constant values for the library."""
2 | import pydantic
3 |
4 |
5 | class Defaults(pydantic.BaseModel):
6 | """Collection of constant values."""
7 |
8 | electricity_prices: float = 100.0
9 | export_electricity_prices: float = 100.0
10 | electricity_carbon_intensities: float = 0.1
11 | gas_prices: float = 20
12 | freq_mins: int = 60
13 |
14 | high_temperature_load_mwh: float = 0
15 | low_temperature_load_mwh: float = 0
16 | low_temperature_generation_mwh: float = 0
17 | electric_load_mwh: float = 0
18 |
19 | # setting this too high will break the evs... was as 1e10
20 | spill_objective_penalty: float = 1e11
21 |
22 | boiler_efficiency_pct: float = 0.8
23 | boiler_high_temperature_generation_max_mw: float = 10000.0
24 |
25 | gas_carbon_intensity: float = 0.185
26 |
27 | spill_charge_max_mw: float = 1e4
28 | spill_capacity_mwh: float = 1e4
29 |
30 | decimal_tolerance: int = 4
31 |
32 | # used for < 0 stuff
33 | epsilon: float = -1e-4
34 |
35 | log_level: int = 2
36 |
37 |
38 | defaults = Defaults()
39 |
--------------------------------------------------------------------------------
/energypylinear/flags.py:
--------------------------------------------------------------------------------
1 | """Toggles to change simulation behaviour."""
2 | import pydantic
3 |
4 |
5 | class Flags(pydantic.BaseModel):
6 | """Toggles to change simulation behaviour."""
7 |
8 | # general
9 | fail_on_spill_asset_use: bool = False
10 | allow_infeasible: bool = False
11 |
12 | # evs
13 | allow_evs_discharge: bool = False
14 | limit_charge_variables_to_valid_events: bool = False
15 |
--------------------------------------------------------------------------------
/energypylinear/freq.py:
--------------------------------------------------------------------------------
1 | """Handles conversion of power (MW) to energy (MWh) at different interval frequencies."""
2 |
3 |
4 | class Freq:
5 | """Convert power and energy measurements for interval data frequencies."""
6 |
7 | def __init__(self, mins: int) -> None:
8 | """Initialize a Freq class."""
9 | self.mins = mins
10 |
11 | def mw_to_mwh(self, mw: float) -> float:
12 | """Convert power MW to energy MWh."""
13 | return mw * self.mins / 60
14 |
15 | def mwh_to_mw(self, mw: float) -> float:
16 | """Convert energy MWh to power MW."""
17 | return mw * 60 / self.mins
18 |
19 | def __repr__(self) -> str:
20 | """Control printing."""
21 | return f"Freq(mins={self.mins})"
22 |
--------------------------------------------------------------------------------
/energypylinear/interval_data.py:
--------------------------------------------------------------------------------
1 | """Models for interval data for electricity & gas prices, thermal loads and carbon intensities."""
2 | import typing
3 |
4 | import numpy as np
5 |
6 | import energypylinear as epl
7 | from energypylinear.assets.asset import AssetOneInterval
8 | from energypylinear.assets.site import SiteOneInterval
9 |
10 | floats = typing.Union[float, np.ndarray, typing.Sequence[float], list[float]]
11 |
12 | AssetOneIntervalType = typing.TypeVar("AssetOneIntervalType", bound=AssetOneInterval)
13 |
14 |
15 | class IntervalVars:
16 | """Interval data of linear program variables."""
17 |
18 | def __init__(self) -> None:
19 | """Initializes the interval variables object."""
20 | self.objective_variables: list[list[AssetOneInterval]] = []
21 |
22 | def __repr__(self) -> str:
23 | """A string representation of self."""
24 | return f""
25 |
26 | def __len__(self) -> int:
27 | """Return the number of objective variable lists."""
28 | return len(self.objective_variables)
29 |
30 | def __getitem__(self, index: int) -> list[AssetOneInterval]:
31 | """Enable subscripting to get a list of AssetOneInterval at a given index."""
32 | return self.objective_variables[index]
33 |
34 | def append(self, one_interval: AssetOneInterval | SiteOneInterval | list) -> None:
35 | """Appends a one_interval object to the appropriate attribute.
36 |
37 | Args:
38 | one_interval (Union[AssetOneInterval, SiteOneInterval, list[AssetOneInterval]]): The interval data to append.
39 | """
40 | assert isinstance(one_interval, list)
41 | self.objective_variables.append(one_interval)
42 |
43 | def filter_objective_variables(
44 | self,
45 | i: int,
46 | instance_type: type[AssetOneInterval] | str | None = None,
47 | asset_name: str | None = None,
48 | ) -> list[AssetOneInterval]:
49 | """Filters objective variables based on type, interval index, and asset name."""
50 | if isinstance(instance_type, str):
51 | type_mapper: dict[str, type | None] = {
52 | "battery": epl.assets.battery.BatteryOneInterval,
53 | "boiler": epl.assets.boiler.BoilerOneInterval,
54 | "chp": epl.assets.chp.CHPOneInterval,
55 | "evs": epl.assets.evs.EVOneInterval,
56 | "heat-pump": epl.assets.heat_pump.HeatPumpOneInterval,
57 | "renewable-generator": epl.assets.renewable_generator.RenewableGeneratorOneInterval,
58 | "site": SiteOneInterval,
59 | "spill": epl.assets.spill.SpillOneInterval,
60 | "spill_evs": epl.assets.evs.EVSpillOneInterval,
61 | "*": None,
62 | }
63 | instance_type = type_mapper[instance_type]
64 |
65 | if instance_type is not None:
66 | assert issubclass(instance_type, AssetOneInterval)
67 |
68 | assets = self.objective_variables[i]
69 | return [
70 | asset
71 | for asset in assets
72 | if (isinstance(asset, instance_type) if instance_type is not None else True)
73 | and (asset.cfg.name == asset_name if asset_name is not None else True)
74 | ]
75 |
76 | def filter_objective_variables_all_intervals(
77 | self,
78 | instance_type: type[AssetOneInterval] | str | None = None,
79 | asset_name: str | None = None,
80 | ) -> list[list[AssetOneInterval]]:
81 | """Filters objective variables based on type and asset name."""
82 | pkg = []
83 | for assets_one_interval in self.objective_variables:
84 | if isinstance(instance_type, str):
85 | type_mapper: dict[str, type | None] = {
86 | "battery": epl.assets.battery.BatteryOneInterval,
87 | "boiler": epl.assets.boiler.BoilerOneInterval,
88 | "chp": epl.assets.chp.CHPOneInterval,
89 | "evs": epl.assets.evs.EVOneInterval,
90 | "heat-pump": epl.assets.heat_pump.HeatPumpOneInterval,
91 | "renewable-generator": epl.assets.renewable_generator.RenewableGeneratorOneInterval,
92 | "site": SiteOneInterval,
93 | "spill": epl.assets.spill.SpillOneInterval,
94 | "spill_evs": epl.assets.evs.EVSpillOneInterval,
95 | "*": None,
96 | }
97 | instance_type = type_mapper[instance_type]
98 |
99 | pkg.append(
100 | [
101 | asset
102 | for asset in assets_one_interval
103 | if (
104 | isinstance(asset, instance_type)
105 | if instance_type is not None
106 | else True
107 | )
108 | and (
109 | asset.cfg.name == asset_name if asset_name is not None else True
110 | )
111 | ]
112 | )
113 | return pkg
114 |
--------------------------------------------------------------------------------
/energypylinear/logger.py:
--------------------------------------------------------------------------------
1 | """Logging to console."""
2 | import logging
3 | import logging.handlers
4 |
5 | from rich.console import Console
6 | from rich.logging import RichHandler
7 |
8 | from energypylinear.defaults import defaults
9 |
10 | console = Console(width=80)
11 |
12 | logger = logging.getLogger("energypylinear")
13 | logger.setLevel(logging.DEBUG)
14 |
15 | rich_handler = RichHandler(
16 | console=console,
17 | rich_tracebacks=True,
18 | show_level=True,
19 | show_time=False,
20 | show_path=False,
21 | )
22 | rich_handler.setLevel(defaults.log_level * 10)
23 | logger.addHandler(rich_handler)
24 |
25 |
26 | def set_logging_level(logger: logging.Logger, level: int | bool) -> None:
27 | """Sets the logging level for the logger handlers.
28 |
29 | Args:
30 | level (int): The new logging level to set.
31 | """
32 | if isinstance(level, bool):
33 | if level is True:
34 | level = defaults.log_level
35 | else:
36 | # error
37 | level = 4
38 |
39 | for handler in logger.handlers:
40 | if isinstance(handler, RichHandler):
41 | if level < 10:
42 | level = level * 10
43 |
44 | handler.setLevel(level)
45 |
--------------------------------------------------------------------------------
/energypylinear/results/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ADGEfficiency/energy-py-linear/85af22bc3d7631b889c9dc207bfbbf01516ae632/energypylinear/results/__init__.py
--------------------------------------------------------------------------------
/energypylinear/results/checks.py:
--------------------------------------------------------------------------------
1 | """Extract results from a solved linear program to pd.DataFrame's."""
2 |
3 | import json
4 |
5 | import pandas as pd
6 |
7 | from energypylinear.defaults import defaults
8 | from energypylinear.logger import logger, set_logging_level
9 | from energypylinear.optimizer import Optimizer
10 |
11 | optimizer = Optimizer()
12 |
13 |
14 | def aggregate_check(debug: pd.DataFrame, non_sum_aggs: dict | None = None) -> str:
15 | """Creates a pretty JSON string of aggregated data from a DataFrame."""
16 | aggs = {k: "sum" for k in debug.keys()}
17 |
18 | if non_sum_aggs:
19 | for k, v in non_sum_aggs.items():
20 | aggs[k] = v
21 |
22 | aggregated = debug.agg(aggs)
23 | aggregated = aggregated.to_dict()
24 | return json.dumps({f"{aggs[k]}-{k}": v for k, v in aggregated.items()}, indent=2)
25 |
26 |
27 | def check_electricity_balance(
28 | simulation: pd.DataFrame,
29 | verbose: int | bool = defaults.log_level,
30 | ) -> pd.DataFrame:
31 | """Checks the electricity balance."""
32 | inp = (
33 | simulation["site-import_power_mwh"]
34 | + simulation["total-electric_generation_mwh"]
35 | )
36 | out = simulation["site-export_power_mwh"] + simulation["total-electric_load_mwh"]
37 | accumulation = (
38 | simulation["total-electric_discharge_mwh"]
39 | - simulation["total-electric_charge_mwh"]
40 | )
41 |
42 | balance = abs(inp + accumulation - out) < 1e-3
43 |
44 | soc = simulation[[c for c in simulation.columns if "final_soc" in c]].sum(axis=1)
45 | debug = pd.DataFrame(
46 | {
47 | "input": inp,
48 | "accumulation": accumulation,
49 | "output": out,
50 | "balance": balance,
51 | "import": simulation["site-import_power_mwh"],
52 | "generation": simulation["total-electric_generation_mwh"],
53 | "export": simulation["site-export_power_mwh"],
54 | "load": simulation["total-electric_load_mwh"],
55 | "charge": simulation["total-electric_charge_mwh"],
56 | "discharge": simulation["total-electric_discharge_mwh"],
57 | "loss": simulation["total-electric_loss_mwh"],
58 | "spills": simulation["total-spills_mwh"],
59 | "soc": soc,
60 | }
61 | )
62 |
63 | aggregated = aggregate_check(debug, {"balance": "all", "soc": "mean"})
64 | logger.debug(f"checks.check_electricity_balance: aggs={aggregated}")
65 | assert balance.all(), aggregated
66 | return debug
67 |
68 |
69 | def check_high_temperature_heat_balance(
70 | simulation: pd.DataFrame,
71 | total_mapper: dict | None = None,
72 | verbose: int | bool = defaults.log_level,
73 | ) -> pd.DataFrame:
74 | """Checks the high temperature heat balance."""
75 | inp = simulation["total-high_temperature_generation_mwh"]
76 | out = simulation["total-high_temperature_load_mwh"]
77 | balance = abs(inp - out) < 1e-4
78 |
79 | debug = pd.DataFrame(
80 | {
81 | "in": inp,
82 | "out": out,
83 | "balance": balance,
84 | }
85 | )
86 | if total_mapper:
87 | for key in ["high_temperature_generation_mwh", "high_temperature_load_mwh"]:
88 | for col in total_mapper[key]:
89 | debug[col] = simulation[col]
90 |
91 | aggregated = aggregate_check(debug, {"balance": "all"})
92 | logger.debug(f"checks.check_high_temperature_heat_balance: aggs={aggregated}")
93 | assert balance.all(), aggregated
94 | return debug
95 |
96 |
97 | def check_low_temperature_heat_balance(
98 | simulation: pd.DataFrame,
99 | total_mapper: dict | None = None,
100 | verbose: int | bool = defaults.log_level,
101 | ) -> pd.DataFrame:
102 | """Checks the high temperature heat balance."""
103 | inp = simulation["total-low_temperature_generation_mwh"]
104 | out = simulation["total-low_temperature_load_mwh"]
105 | balance = abs(inp - out) < 1e-4
106 |
107 | debug = pd.DataFrame(
108 | {
109 | "in": inp,
110 | "out": out,
111 | "balance": balance,
112 | }
113 | )
114 | if total_mapper:
115 | for key in ["low_temperature_generation_mwh", "low_temperature_load_mwh"]:
116 | for col in total_mapper[key]:
117 | debug[col] = simulation[col]
118 |
119 | aggregated = aggregate_check(debug, {"balance": "all"})
120 | logger.debug(f"checks.check_low_temperature_heat_balance: aggs={aggregated}")
121 | assert balance.all(), aggregated
122 | return debug
123 |
124 |
125 | def check_results(
126 | results: pd.DataFrame,
127 | total_mapper: dict | None = None,
128 | verbose: int | bool = defaults.log_level,
129 | check_valve: bool = False,
130 | check_evs: bool = False,
131 | ) -> dict:
132 | """Check that our simulation results make sense.
133 |
134 | Args:
135 | interval_data: input interval data to the simulation.
136 | simulation: simulation results.
137 | """
138 | set_logging_level(logger, verbose)
139 | electricity_balance = check_electricity_balance(results, verbose)
140 | ht_balance = check_high_temperature_heat_balance(
141 | results,
142 | total_mapper,
143 | verbose,
144 | )
145 | lt_balance = check_low_temperature_heat_balance(
146 | results,
147 | total_mapper,
148 | verbose,
149 | )
150 |
151 | # TODO could be refactored into `check_valve_heat_balance`
152 | if check_valve:
153 | assert all(
154 | results["valve-low_temperature_generation_mwh"]
155 | == results["valve-high_temperature_load_mwh"]
156 | )
157 |
158 | if check_valve:
159 | # TODO replace with a check on SOC
160 |
161 | # for charge_event_idx, charge_event_mwh in enumerate(
162 | # interval_data.evs.charge_event_mwh
163 | # ):
164 | # np.testing.assert_almost_equal(
165 | # simulation[f"charge-event-{charge_event_idx}-total-charge_mwh"].sum(),
166 | # charge_event_mwh,
167 | # decimal=defaults.decimal_tolerance,
168 | # )
169 | """
170 | want to check
171 | - only one charger -> one charge event each interval
172 | """
173 | cols = [
174 | c
175 | for c in results.columns
176 | if c.startswith("charger-")
177 | and c.endswith("-charge_binary")
178 | and "spill" not in c
179 | ]
180 | subset = results[cols]
181 | assert (subset <= 1).all().all()
182 |
183 | return {
184 | "electricity-balance": electricity_balance,
185 | "high-temperature-heat-balance": ht_balance,
186 | "low-temperature-heat-balance": lt_balance,
187 | }
188 |
--------------------------------------------------------------------------------
/energypylinear/results/schema.py:
--------------------------------------------------------------------------------
1 | """Schema for simulation results."""
2 | import pandera as pa
3 |
4 | from energypylinear.assets.asset import AssetOneInterval
5 | from energypylinear.defaults import defaults
6 | from energypylinear.optimizer import Optimizer
7 |
8 | optimizer = Optimizer()
9 |
10 |
11 | schema = {
12 | "site-import_power_mwh": pa.Column(
13 | pa.Float,
14 | checks=[pa.Check.ge(defaults.epsilon)],
15 | title="Site Import Power MWh",
16 | coerce=True,
17 | ),
18 | "site-export_power_mwh": pa.Column(
19 | pa.Float,
20 | checks=[pa.Check.ge(defaults.epsilon)],
21 | title="Site Export Power MWh",
22 | coerce=True,
23 | ),
24 | }
25 | # maybe could get this from epl.assets.AssetOneInterval ?
26 | quantities = [
27 | "electric_generation_mwh",
28 | "electric_load_mwh",
29 | "high_temperature_generation_mwh",
30 | "low_temperature_generation_mwh",
31 | "high_temperature_load_mwh",
32 | "low_temperature_load_mwh",
33 | "gas_consumption_mwh",
34 | "electric_charge_mwh",
35 | "electric_discharge_mwh",
36 | ]
37 |
38 | for qu in [
39 | q for q in AssetOneInterval.model_fields if (q != "cfg") and (q != "binary")
40 | ]:
41 | schema[rf"\w+-{qu}"] = pa.Column(
42 | pa.Float, checks=[pa.Check.ge(defaults.epsilon)], coerce=True, regex=True
43 | )
44 | schema[f"total-{qu}"] = pa.Column(
45 | pa.Float,
46 | checks=[pa.Check.ge(defaults.epsilon)],
47 | coerce=True,
48 | regex=True,
49 | required=True,
50 | )
51 | simulation_schema = pa.DataFrameSchema(schema)
52 |
--------------------------------------------------------------------------------
/energypylinear/results/warnings.py:
--------------------------------------------------------------------------------
1 | """Warnings for results module."""
2 | import pandas as pd
3 |
4 | from energypylinear.defaults import defaults
5 | from energypylinear.flags import Flags
6 | from energypylinear.logger import logger
7 | from energypylinear.optimizer import Optimizer
8 |
9 | optimizer = Optimizer()
10 |
11 |
12 | def warn_spills(
13 | simulation: pd.DataFrame, flags: Flags, verbose: int | bool = defaults.log_level
14 | ) -> bool:
15 | """Prints warnings if we have spilled."""
16 | spill_columns = [
17 | c for c in simulation.columns if ("spill" in c) and ("charge_binary" not in c)
18 | ]
19 | spill_results = simulation[spill_columns]
20 | assert isinstance(spill_results, pd.DataFrame)
21 | spill_occured = spill_results.sum().sum() > abs(defaults.epsilon)
22 |
23 | spills = spill_results.sum(axis=0).to_dict()
24 | spills = {k: v for k, v in spills.items() if v > 0}
25 | if spill_occured and flags.fail_on_spill_asset_use:
26 | spill_message = f"""
27 | Spill Occurred!
28 | {len(spills)} of {spill_results.shape[1]} spill columns
29 | {spills}
30 | """
31 | raise ValueError(spill_message)
32 | elif spill_occured and verbose:
33 | logger.warning(
34 | f"warnings.warn_spills: n_spills={len(spills)}, spill_columns={spill_results.shape[1]}, spills={spills}"
35 | )
36 | return spill_occured
37 |
--------------------------------------------------------------------------------
/energypylinear/utils.py:
--------------------------------------------------------------------------------
1 | """Utility functions."""
2 | import numpy as np
3 |
4 |
5 | def repeat_to_match_length(a: np.ndarray, b: np.ndarray) -> np.ndarray:
6 | """Repeats an array to match the length of another array."""
7 | quotient, remainder = divmod(len(b), len(a))
8 | return np.concatenate([np.tile(a, quotient), a[:remainder]])
9 |
10 |
11 | def check_array_lengths(results: dict[str, list]) -> None:
12 | """Check that all lists in the results dictionary have the same length.
13 |
14 | Args:
15 | results (dict[str, list]):
16 | Dictionary containing lists whose lengths need to be checked.
17 |
18 | Raises:
19 | AssertionError: If lists in the dictionary have different lengths.
20 | """
21 | lens = []
22 | dbg = []
23 | for k, v in results.items():
24 | lens.append(len(v))
25 | dbg.append((k, len(v)))
26 | assert len(set(lens)) == 1, f"{len(set(lens))} {dbg}"
27 |
--------------------------------------------------------------------------------
/poc/constraint-fun.py:
--------------------------------------------------------------------------------
1 | efficiency = 0.9
2 | datas = [
3 | {"charge": 1.0, "bin": 1, "losses": 0.1},
4 | {"charge": 0.5, "bin": 1, "losses": 0.05},
5 | {"charge": -1.0, "bin": 0},
6 | # {"charge": 1.0, "bin": 0, "valid": False},
7 | # {"charge": -1.0, "bin": 1, "valid": False},
8 | ]
9 | M = 1000
10 | for data in datas:
11 | # losses = data["losses"]
12 | losses = data.get("losses", 0)
13 | charge = data["charge"]
14 | bin = data["bin"]
15 |
16 | valid = data.get("valid", True)
17 |
18 | print(f"{valid=}")
19 | print(charge <= M * bin)
20 | print(charge >= -M * (1 - bin))
21 | """
22 | y <= p * x * b
23 | y >= p * x * b
24 | y <= M * (1 - b)
25 | """
26 |
27 | print(losses <= efficiency * charge * bin)
28 | print(losses >= efficiency * charge * bin)
29 |
--------------------------------------------------------------------------------
/poc/multi-site.py:
--------------------------------------------------------------------------------
1 | # test_multisite.py
2 | """
3 | how to do multi-subsite
4 |
5 | `top-level` sites attach to the grid - site import = grid
6 |
7 | `sub-level` sites attach to other sites - site import = is coming from other site
8 |
9 | also have this idea of generation / load - is that different from site import / export? not really
10 |
11 | want to rewrite it so that
12 |
13 | epl.Site(),
14 | 1. site import / export is handled by adding a grid connection as an asset to a site?
15 |
16 | asset = [epl.GridConnection()]
17 |
18 | what is a site? - site is list of assets - a site can be an asset
19 | """
20 |
21 | import typing
22 |
23 | import energypylinear as epl
24 | from energypylinear.assets.site import SiteConfig
25 |
26 |
27 | def constrain_site_electricity_balance(
28 | optimizer: Optimizer,
29 | vars: dict,
30 | interval_data: "epl.interval_data.IntervalData",
31 | i: int,
32 | ) -> None:
33 | """Constrain site electricity balance.
34 |
35 | in = out + accumulation
36 | import + generation = (export + load) + (charge - discharge)
37 | import + generation - (export + load) - (charge - discharge) = 0
38 | """
39 | assets = vars["assets"][-1]
40 | spills = vars.get("spills")
41 |
42 | optimizer.constrain(
43 | (
44 | optimizer.sum([a.electric_generation_mwh for a in assets])
45 | - optimizer.sum([a.electric_load_mwh for a in assets])
46 | - optimizer.sum([a.charge_mwh for a in assets])
47 | + optimizer.sum([a.discharge_mwh for a in assets])
48 | + (spills[-1].electric_generation_mwh if spills else 0)
49 | - (spills[-1].electric_load_mwh if spills else 0)
50 | - interval_data.electric_load_mwh[i]
51 | )
52 | == 0
53 | )
54 |
55 |
56 | class GridConnectionConfig(pydantic.BaseModel):
57 | """Site configuration."""
58 |
59 | import_limit_mw: float = 10000
60 | export_limit_mw: float = 10000
61 |
62 |
63 | class GridConnectionOneInterval(pydantic.BaseModel):
64 | """Site data for a single interval."""
65 |
66 | # import power
67 | electric_generation_mwh: pulp.LpVariable
68 | # export power
69 | electric_load_mwh: pulp.LpVariable
70 |
71 | import_power_bin: pulp.LpVariable
72 | export_power_bin: pulp.LpVariable
73 |
74 | import_limit_mwh: float
75 | export_limit_mwh: float
76 |
77 | class Config:
78 | """pydantic.BaseModel configuration."""
79 |
80 | arbitrary_types_allowed: bool = True
81 |
82 |
83 | class GridConnection:
84 | def __init__(
85 | self,
86 | cfg: GridConnectionConfig = GridConnectionConfig(),
87 | ):
88 | self.assets = assets
89 | self.cfg = cfg
90 |
91 | def one_interval(
92 | self, optimizer: Optimizer, site: SiteConfig, i: int, freq: Freq
93 | ) -> SiteOneInterval:
94 | """Create Site asset data for a single interval."""
95 | return SiteOneInterval(
96 | import_power_mwh=optimizer.continuous(
97 | f"import_power_mw-{i}", up=freq.mw_to_mwh(site.import_limit_mw)
98 | ),
99 | export_power_mwh=optimizer.continuous(
100 | f"export_power_mw-{i}", up=freq.mw_to_mwh(site.import_limit_mw)
101 | ),
102 | import_power_bin=optimizer.binary(f"import_power_bin-{i}"),
103 | export_power_bin=optimizer.binary(f"export_power_bin-{i}"),
104 | import_limit_mwh=freq.mw_to_mwh(site.import_limit_mw),
105 | export_limit_mwh=freq.mw_to_mwh(site.export_limit_mw),
106 | )
107 |
108 |
109 | class MultiSite:
110 | def __init__(
111 | self,
112 | assets: typing.Optional[list] = None,
113 | cfg: SiteConfig = SiteConfig(),
114 | ):
115 | self.assets = assets
116 | self.cfg = cfg
117 |
118 | def __repr__(self) -> str:
119 | return f""
120 |
121 | def constrain_within_interval(
122 | self,
123 | optimizer: Optimizer,
124 | vars: dict,
125 | interval_data: "epl.interval_data.IntervalData",
126 | i: int,
127 | ) -> None:
128 | """Constrain site within a single interval."""
129 | constrain_site_electricity_balance(optimizer, vars, interval_data, i)
130 | # constrain_site_import_export(optimizer, vars)
131 | # constrain_site_high_temperature_heat_balance(optimizer, vars, interval_data, i)
132 | # constrain_site_low_temperature_heat_balance(optimizer, vars, interval_data, i)
133 |
134 | def optimize(self):
135 | for i in interval_data.idx:
136 | assets = []
137 |
138 | for asset in self.assets:
139 | assets.extend([asset.one_interval(self.optimizer, i, freq, flags)])
140 |
141 | vars["assets"].append(assets)
142 | self.constrain_within_interval(self.optimizer, vars, interval_data, i)
143 |
144 |
145 | def test_multisite():
146 | site = epl.Site(
147 | assets=[GridConnection(), epl.Battery(), epl.Site(assets=[epl.Battery()])]
148 | )
149 | breakpoint() # fmt: skip
150 |
--------------------------------------------------------------------------------
/poc/schematic.py:
--------------------------------------------------------------------------------
1 | """Draw a diagram / plot of the system at a single point in time."""
2 |
3 | import matplotlib
4 | import matplotlib.pyplot as plt
5 | import pydantic
6 |
7 | import energypylinear as epl
8 |
9 |
10 | class SchematicConfig(pydantic.BaseModel):
11 | fig_height_cm: float = 10
12 | fig_width_cm: float = 13
13 | dpi: float = 300
14 | line_color: str = "dimgrey"
15 |
16 | # headers
17 | header_x: float = 1.0
18 | incomer_height: float = 9
19 | electric_header_height: float = 8
20 | ht_header_height: float = 5
21 | lt_header_height: float = 2
22 |
23 | # assets
24 | generator_height: float = 6
25 | ht_height: float = 3
26 | lt_height: float = 0
27 |
28 | label_size: float = 8
29 |
30 |
31 | def get_fig(cfg: SchematicConfig, remove_ticks: bool = False):
32 | fig, ax = plt.subplots(
33 | figsize=[cfg.fig_width_cm / 2.54, cfg.fig_height_cm / 2.54], dpi=cfg.dpi
34 | )
35 |
36 | # set ax limits so 1 unit = 1 cm
37 | ax.set_xlim(0, cfg.fig_width_cm)
38 | ax.set_ylim(0, cfg.fig_height_cm)
39 | ax.autoscale_view("tight")
40 |
41 | # turn off tick labels
42 | if remove_ticks:
43 | ax.tick_params(bottom=False, top=False, left=False, right=False)
44 | ax.tick_params(
45 | labelbottom=False, labeltop=False, labelleft=False, labelright=False
46 | )
47 | plt.tight_layout()
48 | return fig, ax
49 |
50 |
51 | def plot_header_or_busbar(
52 | x: float,
53 | y: float,
54 | name: str,
55 | ):
56 | # title
57 | ax.annotate(name, (0.3, x + 0.1), horizontalalignment="left")
58 | # line
59 | ax.plot((y, 12), (x, x), color="black")
60 |
61 |
62 | def draw_generator(
63 | x,
64 | y,
65 | electric=True,
66 | high_temperature=True,
67 | low_temperature=True,
68 | ):
69 | patches = [
70 | matplotlib.patches.Rectangle(xy=(x, y), width=1.0, height=1.0),
71 | ]
72 | if high_temperature:
73 | patches.append(
74 | matplotlib.patches.FancyArrow(
75 | x + 1 / 3, 6, 0, -1, width=0.05, length_includes_head=True
76 | )
77 | )
78 | if low_temperature:
79 | patches.append(
80 | matplotlib.patches.FancyArrow(
81 | x + 2 / 3, 6, 0, -4, width=0.05, length_includes_head=True
82 | )
83 | )
84 | if electric:
85 | patches.append(
86 | matplotlib.patches.FancyArrow(
87 | x + 1 / 2, 7, 0, 1, width=0.05, length_includes_head=True
88 | )
89 | )
90 |
91 | ax.annotate(
92 | "CHP",
93 | (x + 1 / 2, y + 1 / 2),
94 | size=cfg.label_size,
95 | horizontalalignment="center",
96 | verticalalignment="center",
97 | )
98 |
99 | return patches
100 |
101 |
102 | def draw_battery(x, y):
103 | patches = [
104 | matplotlib.patches.Rectangle(xy=(x, y), width=1.0, height=1.0),
105 | ]
106 | patches.append(
107 | matplotlib.patches.FancyArrow(
108 | x + 1 / 3, 7, 0, 1, width=0.05, length_includes_head=True
109 | )
110 | )
111 | patches.append(
112 | matplotlib.patches.FancyArrow(
113 | x + 2 / 3, 8, 0, -1, width=0.05, length_includes_head=True
114 | )
115 | )
116 | ax.annotate(
117 | "BAT",
118 | (x + 1 / 2, y + 1 / 2),
119 | size=cfg.label_size,
120 | horizontalalignment="center",
121 | verticalalignment="center",
122 | )
123 | return patches
124 |
125 |
126 | def draw_load(x, y, header_height, name):
127 | patches = [
128 | matplotlib.patches.Rectangle(xy=(x, y), width=1.0, height=1.0),
129 | ]
130 | patches.append(
131 | matplotlib.patches.FancyArrow(
132 | x + 1 / 2, header_height, 0, -1, width=0.05, length_includes_head=True
133 | )
134 | )
135 | ax.annotate(
136 | name,
137 | (x + 1 / 2, y + 1 / 2),
138 | size=cfg.label_size,
139 | horizontalalignment="center",
140 | verticalalignment="center",
141 | )
142 | return patches
143 |
144 |
145 | def draw_boiler(x, y, header_height):
146 | patches = [
147 | matplotlib.patches.Rectangle(xy=(x, y), width=1.0, height=1.0),
148 | ]
149 | patches.append(
150 | matplotlib.patches.FancyArrow(
151 | x + 1 / 2, header_height - 1, 0, 1, width=0.05, length_includes_head=True
152 | )
153 | )
154 | ax.annotate(
155 | "BLR",
156 | (x + 1 / 2, y + 1 / 2),
157 | size=cfg.label_size,
158 | horizontalalignment="center",
159 | verticalalignment="center",
160 | )
161 | return patches
162 |
163 |
164 | def draw_incomer(x, y, header_height):
165 | patches = [
166 | matplotlib.patches.Rectangle(xy=(x, y), width=1.0, height=1.0),
167 | ]
168 | patches.append(
169 | matplotlib.patches.FancyArrow(
170 | x + 1 / 3, header_height, 0, 1, width=0.05, length_includes_head=True
171 | )
172 | )
173 | patches.append(
174 | matplotlib.patches.FancyArrow(
175 | x + 2 / 3, header_height + 1, 0, -1, width=0.05, length_includes_head=True
176 | )
177 | )
178 | ax.annotate(
179 | "GRID",
180 | (x + 1 / 2, y + 1 / 2),
181 | size=cfg.label_size,
182 | horizontalalignment="center",
183 | verticalalignment="center",
184 | )
185 |
186 | return patches
187 |
188 |
189 | if __name__ == "__main__":
190 | cfg = SchematicConfig()
191 |
192 | asset = epl.chp.Generator(
193 | electric_power_max_mw=100,
194 | electric_power_min_mw=50,
195 | electric_efficiency_pct=0.3,
196 | high_temperature_efficiency_pct=0.5,
197 | )
198 | results = asset.optimize(
199 | electricity_prices=[1000, -100, 1000],
200 | gas_prices=20,
201 | high_temperature_load_mwh=[20, 20, 1000],
202 | freq_mins=60,
203 | )
204 |
205 | fig, ax = get_fig(cfg, remove_ticks=False)
206 |
207 | plot_header_or_busbar(8.0, cfg.header_x, "Electric")
208 | plot_header_or_busbar(cfg.ht_header_height, cfg.header_x, "HT")
209 | plot_header_or_busbar(cfg.lt_header_height, cfg.header_x, "LT")
210 |
211 | collection = []
212 | collection.extend(draw_generator(6, cfg.generator_height))
213 | collection.extend(draw_generator(8, cfg.generator_height))
214 | collection.extend(draw_battery(10, cfg.generator_height))
215 | collection.extend(
216 | draw_load(2, cfg.generator_height, cfg.electric_header_height, "LOAD")
217 | )
218 | collection.extend(draw_load(2, cfg.ht_height, cfg.ht_header_height, "LOAD"))
219 | collection.extend(draw_load(2, cfg.lt_height, cfg.lt_header_height, "LOAD"))
220 |
221 | collection.extend(draw_boiler(4, cfg.ht_height, cfg.ht_header_height))
222 | collection.extend(draw_boiler(4, cfg.lt_height, cfg.lt_header_height))
223 | collection.extend(draw_incomer(4, cfg.incomer_height, cfg.electric_header_height))
224 |
225 | pc = matplotlib.collections.PatchCollection(collection)
226 | ax.add_collection(pc)
227 | fig.savefig("./temp.png")
228 |
--------------------------------------------------------------------------------
/poetry.toml:
--------------------------------------------------------------------------------
1 | [virtualenvs]
2 | create = false
3 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "energypylinear"
3 | version = "1.4.1"
4 | description = "Optimizing energy assets with mixed-integer linear programming."
5 | authors = ["Adam Green "]
6 | license = "GNU GPLv3"
7 | readme = "README.md"
8 |
9 | [tool.poetry.dependencies]
10 | python = "^3.11,<3.13"
11 | PuLP = "^2.7.0"
12 | numpy = "^1.26"
13 | pydantic = "^2.0"
14 | pandas = "^2.0"
15 | matplotlib = "^3.6.2"
16 | rich = "^12.0.0"
17 | seaborn = "^0.12.2"
18 | pandera = "^0.14.5"
19 | markdown-include = "^0.8.1"
20 |
21 | [tool.poetry.group.check]
22 | optional = true
23 | [tool.poetry.group.check.dependencies]
24 | isort = "^5.10.1"
25 | flake8-docstring-checker = "^1.1"
26 | ruff = "^0.1.5"
27 | darglint = "^1.8.1"
28 |
29 | [tool.poetry.group.test]
30 | optional = true
31 | [tool.poetry.group.test.dependencies]
32 | pytest = "^7.2.0"
33 | coverage = "^7.2.0"
34 | hypothesis = "^6.61.0"
35 | phmdoctest = "^1.4.0"
36 | nbmake = "^1.3.5"
37 | pytest-sugar = "^0.9.6"
38 | coverage-badge = "^1.1.0"
39 | pytest-xdist = "^3.3.1"
40 | pytest-cov = "^4.1.0"
41 | beautifulsoup4 = "^4.12.2"
42 |
43 | [tool.poetry.group.develop]
44 | optional = true
45 | [tool.poetry.group.develop.dependencies]
46 | ipython = "^8.7.0"
47 |
48 | [tool.poetry.group.static]
49 | optional = true
50 | [tool.poetry.group.static.dependencies]
51 | mypy = "^1.7.0"
52 |
53 | [build-system]
54 | requires = [
55 | "poetry-core",
56 | "setuptools>=60",
57 | "setuptools-scm>=8.0"
58 | ]
59 | build-backend = "poetry.core.masonry.api"
60 |
61 | [tool.setuptools_scm]
62 |
63 | [tool.mypy]
64 | disallow_any_generics = false
65 | disallow_incomplete_defs = true
66 | disallow_untyped_calls = true
67 | disallow_untyped_defs = true
68 | implicit_reexport = true
69 | warn_redundant_casts = true
70 | warn_unused_ignores = true
71 |
72 | [[tool.mypy.overrides]]
73 | module = [
74 | 'pandas',
75 | 'matplotlib',
76 | 'matplotlib.pyplot',
77 | 'matplotlib.patches',
78 | 'pulp',
79 | 'seaborn',
80 | 'bs4',
81 | 'pytest',
82 | '_pytest.capture',
83 | 'hypothesis'
84 | ]
85 | ignore_missing_imports = true
86 |
87 | [tool.coverage.report]
88 | exclude_also =[
89 | "@(abc\\.)?abstractmethod"
90 | ]
91 |
92 | [tool.pylint]
93 | load-plugins = "pylint.extensions.docparams"
94 |
--------------------------------------------------------------------------------
/static/coverage.svg:
--------------------------------------------------------------------------------
1 |
2 |
22 |
--------------------------------------------------------------------------------
/tests/assert-test-coverage.py:
--------------------------------------------------------------------------------
1 | """Test for test coverage by scraping the HTML generated by `coverage`"""
2 | import pathlib
3 |
4 | from bs4 import BeautifulSoup
5 |
6 | if __name__ == "__main__":
7 | threshold = 100.0
8 | html = pathlib.Path("./htmlcov/index.html").read_text()
9 | soup = BeautifulSoup(html, "html.parser")
10 | coverage_span = soup.find("span", class_="pc_cov")
11 | assert coverage_span
12 | coverage_percentage = coverage_span.get_text()
13 | coverage = float(coverage_percentage.replace("%", ""))
14 | assert (
15 | coverage >= threshold
16 | ), f"FAILED: coverage test: {coverage_percentage=} {coverage=} {threshold=}"
17 | print(f"PASSED: coverage test: {coverage=} {threshold=}")
18 |
--------------------------------------------------------------------------------
/tests/assets/test_chp.py:
--------------------------------------------------------------------------------
1 | """Test CHP asset."""
2 | import numpy as np
3 |
4 | import energypylinear as epl
5 | from energypylinear.defaults import defaults
6 |
7 |
8 | def test_chp_gas_turbine_price() -> None:
9 | """Test gas turbine optimization for price."""
10 | asset = epl.CHP(
11 | electric_power_max_mw=100,
12 | electric_power_min_mw=50,
13 | electric_efficiency_pct=0.3,
14 | high_temperature_efficiency_pct=0.5,
15 | electricity_prices=[1000, -100, 1000],
16 | gas_prices=20,
17 | high_temperature_load_mwh=[20, 20, 1000],
18 | freq_mins=60,
19 | name="chp",
20 | include_spill=True,
21 | )
22 | simulation = asset.optimize()
23 | """
24 | - high electricity price, low heat demand
25 | - expect chp to run full load and dump heat to low temperature
26 | """
27 | row = simulation.results.iloc[0, :]
28 | assert row["chp-electric_generation_mwh"] == 100
29 |
30 | np.testing.assert_almost_equal(
31 | row["spill-low_temperature_load_mwh"],
32 | (100 / 0.3) * 0.5 - 20,
33 | decimal=defaults.decimal_tolerance,
34 | )
35 | """
36 | - low electricity price, low heat demand
37 | - expect all heat demand met from boiler
38 | """
39 | row = simulation.results.iloc[1, :]
40 | assert row["chp-electric_generation_mwh"] == 0
41 | assert row["boiler-high_temperature_generation_mwh"] == 20
42 |
43 | """
44 | - high electricity price, high heat demand
45 | - expect chp to run full load and boiler to pick up slack
46 | """
47 | row = simulation.results.iloc[2, :]
48 | assert row["chp-electric_generation_mwh"] == 100
49 | np.testing.assert_almost_equal(
50 | row["boiler-high_temperature_generation_mwh"],
51 | 1000 - (100 / 0.3) * 0.5,
52 | decimal=defaults.decimal_tolerance,
53 | )
54 |
55 | # TODO - should be done elsewhere - just for coverage
56 | asset.site.optimizer.constraints()
57 |
58 |
59 | def test_chp_gas_turbine_carbon() -> None:
60 | """Test gas turbine optimization for carbon."""
61 | asset = epl.CHP(
62 | electric_power_max_mw=100,
63 | electric_power_min_mw=50,
64 | electric_efficiency_pct=0.3,
65 | high_temperature_efficiency_pct=0.5,
66 | electricity_prices=[1000, -100, 1000],
67 | electricity_carbon_intensities=[1.5, 0.1, 1.5],
68 | gas_prices=20,
69 | high_temperature_load_mwh=[20, 20, 1000],
70 | freq_mins=60,
71 | include_spill=True,
72 | )
73 | simulation = asset.optimize(
74 | objective="carbon",
75 | )
76 | """
77 | - high carbon intensity, low heat demand
78 | - expect chp to run full load and dump heat to low temperature
79 | """
80 | row = simulation.results.iloc[0, :]
81 | assert row["chp-electric_generation_mwh"] == 100
82 | np.testing.assert_almost_equal(
83 | row["spill-low_temperature_load_mwh"],
84 | (100 / 0.3) * 0.5 - 20,
85 | decimal=defaults.decimal_tolerance,
86 | )
87 | """
88 | - low carbon intensity, low heat demand
89 | - expect all heat demand met from boiler
90 | """
91 | row = simulation.results.iloc[1, :]
92 | assert row["chp-electric_generation_mwh"] == 0
93 | assert row["boiler-high_temperature_generation_mwh"] == 20
94 |
95 | """
96 | - high carbon intensity, high heat demand
97 | - expect chp to run full load and boiler to pick up slack
98 | """
99 | row = simulation.results.iloc[2, :]
100 | assert row["chp-electric_generation_mwh"] == 100
101 | np.testing.assert_almost_equal(
102 | row["boiler-high_temperature_generation_mwh"],
103 | 1000 - (100 / 0.3) * 0.5,
104 | decimal=defaults.decimal_tolerance,
105 | )
106 |
107 |
108 | def test_chp_gas_engine_price() -> None:
109 | """Test gas engine optimization for price."""
110 | asset = epl.CHP(
111 | electric_power_max_mw=100,
112 | electric_power_min_mw=10,
113 | electric_efficiency_pct=0.4,
114 | high_temperature_efficiency_pct=0.2,
115 | low_temperature_efficiency_pct=0.2,
116 | electricity_prices=[
117 | 1000.0,
118 | ],
119 | gas_prices=20.0,
120 | high_temperature_load_mwh=[
121 | 20.0,
122 | ],
123 | low_temperature_load_mwh=[
124 | 20.0,
125 | ],
126 | freq_mins=60,
127 | include_spill=True,
128 | )
129 | simulation = asset.optimize()
130 | """
131 | - high electricity price, low heat demand
132 | - expect chp to run full load and dump heat
133 | """
134 | row = simulation.results.iloc[0, :]
135 | assert row["chp-electric_generation_mwh"] == 100
136 | np.testing.assert_almost_equal(
137 | row["spill-low_temperature_load_mwh"],
138 | (100 / 0.4) * 0.4 - 40,
139 | decimal=defaults.decimal_tolerance,
140 | )
141 |
142 |
143 | def test_chp_gas_engine_carbon() -> None:
144 | """Test gas engine optimization for carbon."""
145 | asset = epl.CHP(
146 | electric_power_max_mw=100,
147 | electric_power_min_mw=10,
148 | electric_efficiency_pct=0.4,
149 | high_temperature_efficiency_pct=0.2,
150 | low_temperature_efficiency_pct=0.2,
151 | electricity_prices=[0, 0],
152 | electricity_carbon_intensities=[1.5, 0.1],
153 | gas_prices=20.0,
154 | high_temperature_load_mwh=[20.0, 20],
155 | low_temperature_load_mwh=[20.0, 20],
156 | freq_mins=60,
157 | include_spill=True,
158 | )
159 | simulation = asset.optimize(
160 | objective="carbon",
161 | )
162 | """
163 | - high carbon intensity, low heat demand
164 | - expect chp to run full load and dump heat
165 | """
166 | row = simulation.results.iloc[0, :]
167 | assert row["chp-electric_generation_mwh"] == 100
168 | np.testing.assert_almost_equal(
169 | row["spill-low_temperature_load_mwh"],
170 | (100 / 0.4) * 0.4 - 40,
171 | decimal=defaults.decimal_tolerance,
172 | )
173 | """
174 | - low carbon intensity, low heat demand
175 | - expect chp to not run at all
176 | """
177 | row = simulation.results.iloc[1, :]
178 | assert row["chp-electric_generation_mwh"] == 0
179 | np.testing.assert_almost_equal(
180 | row["spill-low_temperature_load_mwh"],
181 | 0,
182 | decimal=defaults.decimal_tolerance,
183 | )
184 |
--------------------------------------------------------------------------------
/tests/assets/test_heat_pump.py:
--------------------------------------------------------------------------------
1 | """Tests for the Heat Pump asset."""
2 | import hypothesis
3 | import numpy as np
4 | import pytest
5 |
6 | import energypylinear as epl
7 | from energypylinear.debug import debug_asset
8 |
9 |
10 | def test_heat_pump_optimization_price() -> None:
11 | """Test optimization for price."""
12 |
13 | gas_price = 20.0
14 | # TODO pass this into model
15 | blr_effy = 0.8
16 | cop = 3.0
17 |
18 | """
19 | cost to supply 1 MWh of high temp heat
20 |
21 | BAU = gas_prices / blr_effy
22 |
23 | heat pump = electricity_price / COP
24 |
25 | breakeven_electricity_price = gas_prices / blr_effy * COP
26 | """
27 | breakeven_electricity_price = gas_price / blr_effy * cop
28 |
29 | tolerance = 1
30 | asset = epl.HeatPump(
31 | electric_power_mw=1.0,
32 | cop=cop,
33 | electricity_prices=[
34 | -100,
35 | -50,
36 | 50,
37 | 100,
38 | breakeven_electricity_price + tolerance,
39 | breakeven_electricity_price - tolerance,
40 | ],
41 | gas_prices=gas_price,
42 | # these are a bit hacky - will be expanded on in later tests
43 | # the low temperature generation is a free source of low temperature heat
44 | # which can be dumped or used by the heat pump to make high temperature heat
45 | high_temperature_load_mwh=100.0,
46 | low_temperature_generation_mwh=100.0,
47 | include_spill=True,
48 | )
49 | simulation = asset.optimize()
50 | results = simulation.results
51 |
52 | # when prices are low, the heat pump works
53 | # when prices are high, the heat pump doesn't work
54 | np.testing.assert_array_equal(
55 | results["site-import_power_mwh"], [1.0, 1.0, 1.0, 0.0, 0.0, 1.0]
56 | )
57 |
58 | # test COP using simulation totals
59 | np.testing.assert_equal(
60 | sum(results["heat-pump-high_temperature_generation_mwh"])
61 | / sum(results["heat-pump-electric_load_mwh"]),
62 | cop,
63 | )
64 |
65 | # test COP by interval
66 | subset = results[results["heat-pump-high_temperature_generation_mwh"] > 0]
67 | np.testing.assert_equal(
68 | subset["heat-pump-high_temperature_generation_mwh"]
69 | / subset["heat-pump-electric_load_mwh"],
70 | np.full_like(subset.iloc[:, 0].values, cop),
71 | )
72 |
73 | # test we don't consume more electricity than the heat pump size
74 | tol = 1e-7
75 | np.testing.assert_array_less(results["heat-pump-electric_load_mwh"], 1.0 + tol)
76 |
77 | # test max sizes
78 | # test we don't consume more electricity than the heat pump size
79 | np.testing.assert_array_less(
80 | results["heat-pump-high_temperature_generation_mwh"], 1.0 * cop + tol
81 | )
82 |
83 | # test the energy balance across the heat pump
84 | # the high temperature heat generated by the heat pump is the sum of the electricity and low temp energy
85 | np.testing.assert_array_equal(
86 | results["heat-pump-high_temperature_generation_mwh"]
87 | - results["heat-pump-electric_load_mwh"]
88 | - results["heat-pump-low_temperature_load_mwh"],
89 | 0.0,
90 | )
91 |
92 |
93 | def test_heat_pump_optimization_carbon() -> None:
94 | """Test optimization for carbon."""
95 | gas_price = 20
96 | # TODO pass this into model
97 | blr_effy = 0.8
98 | cop = 3.0
99 |
100 | """
101 | carbon cost to supply 1 MWh of high temp heat
102 |
103 | BAU = gas_carbon_intensity / blr_effy
104 | heat pump = breakeven_carbon_intensity / COP
105 | breakeven_carbon_intensity = gas_carbon_intensity / blr_effy * COP
106 | """
107 | defaults = epl.defaults.Defaults()
108 |
109 | breakeven_carbon_intensity = defaults.gas_carbon_intensity / blr_effy * cop
110 | tolerance = 0.01
111 |
112 | asset = epl.HeatPump(
113 | electric_power_mw=1.0,
114 | cop=cop,
115 | electricity_prices=6 * [0.0],
116 | electricity_carbon_intensities=[
117 | -2.0,
118 | -0.5,
119 | 1.0,
120 | 2.0,
121 | breakeven_carbon_intensity + tolerance,
122 | breakeven_carbon_intensity - tolerance,
123 | ],
124 | gas_prices=gas_price,
125 | high_temperature_load_mwh=100,
126 | low_temperature_generation_mwh=100,
127 | include_spill=True,
128 | )
129 |
130 | simulation = asset.optimize(objective="carbon")
131 | np.testing.assert_array_equal(
132 | simulation.results["site-import_power_mwh"], [1.0, 1.0, 0.0, 0.0, 0.0, 1.0]
133 | )
134 |
135 |
136 | def test_heat_pump_invalid_cop() -> None:
137 | """Test invalid COP raises error."""
138 | with pytest.raises(ValueError, match="COP must be 1 or above"):
139 | epl.HeatPump(electric_power_mw=2.0, cop=0.5)
140 |
141 |
142 | def test_heat_pump_heat_balance() -> None:
143 | """Test heat balance around heat pump."""
144 |
145 | gas_price = 20
146 | cop = 4.0
147 |
148 | asset = epl.HeatPump(
149 | electric_power_mw=2.0,
150 | cop=cop,
151 | electricity_prices=[-100.0, -100.0, -100.0],
152 | gas_prices=gas_price,
153 | high_temperature_load_mwh=[1, 2.0, 4.0],
154 | low_temperature_generation_mwh=[100, 100, 100],
155 | include_valve=False,
156 | include_spill=True,
157 | )
158 |
159 | # limited by high temperature load
160 | simulation = asset.optimize(verbose=False)
161 |
162 | np.testing.assert_array_equal(
163 | simulation.results["site-import_power_mwh"], [0.25, 0.5, 1.0]
164 | )
165 |
166 | # limited by low temperature generation
167 | asset = epl.HeatPump(
168 | electric_power_mw=2.0,
169 | cop=cop,
170 | electricity_prices=3 * [-100.0],
171 | gas_prices=gas_price,
172 | high_temperature_load_mwh=100,
173 | low_temperature_generation_mwh=[0.25, 0.5, 1.0],
174 | include_valve=False,
175 | include_spill=True,
176 | )
177 | simulation = asset.optimize(
178 | verbose=False,
179 | )
180 |
181 | """
182 | lt + elect = ht
183 | ht = cop * elect
184 |
185 | lt + elect = cop * elect
186 | lt = elect * (cop - 1)
187 | elect = lt / (cop - 1)
188 |
189 | """
190 | np.testing.assert_allclose(
191 | simulation.results["site-import_power_mwh"],
192 | [0.25 / (cop - 1), 0.5 / (cop - 1), 1.0 / (cop - 1)],
193 | )
194 |
195 |
196 | @hypothesis.settings(print_blob=True, deadline=None)
197 | @hypothesis.given(
198 | cop=hypothesis.strategies.floats(min_value=1.0, max_value=50),
199 | idx_length=hypothesis.strategies.integers(min_value=10, max_value=24),
200 | gas_price=hypothesis.strategies.floats(min_value=10, max_value=50),
201 | prices_mu=hypothesis.strategies.floats(min_value=-1000, max_value=1000),
202 | prices_std=hypothesis.strategies.floats(min_value=0.1, max_value=1000),
203 | prices_offset=hypothesis.strategies.floats(min_value=-250, max_value=250),
204 | include_valve=hypothesis.strategies.booleans(),
205 | )
206 | def test_heat_pump_hypothesis(
207 | cop: float,
208 | idx_length: int,
209 | gas_price: float,
210 | prices_mu: float,
211 | prices_std: float,
212 | prices_offset: float,
213 | include_valve: bool,
214 | ) -> None:
215 | """Test optimization with hypothesis."""
216 | electricity_prices = (
217 | np.random.normal(prices_mu, prices_std, idx_length) + prices_offset
218 | )
219 | asset = epl.HeatPump(
220 | electric_power_mw=2.0,
221 | cop=cop,
222 | electricity_prices=electricity_prices,
223 | gas_prices=gas_price,
224 | high_temperature_load_mwh=100,
225 | low_temperature_generation_mwh=100,
226 | include_valve=include_valve,
227 | include_spill=True,
228 | )
229 | simulation = asset.optimize(
230 | verbose=False,
231 | )
232 |
233 | dbg = debug_asset(simulation.results, asset.cfg.name, verbose=False)
234 | dbg["cop-check"] = (
235 | simulation.results["heat-pump-high_temperature_generation_mwh"]
236 | / simulation.results["heat-pump-electric_load_mwh"]
237 | )
238 |
239 | mask = dbg["heat-pump-electric_load_mwh"] == 0
240 | dbg.loc[mask, "cop-check"] = 0.0
241 |
242 | cop_check_rhs = np.full_like(dbg.iloc[:, 0].values, cop)
243 | cop_check_rhs[mask] = 0.0
244 |
245 | np.testing.assert_allclose(dbg["cop-check"], cop_check_rhs)
246 |
--------------------------------------------------------------------------------
/tests/assets/test_renewable_generator.py:
--------------------------------------------------------------------------------
1 | """Tests for the Renewable Generator asset."""
2 | import hypothesis
3 | import numpy as np
4 | import pytest
5 |
6 | import energypylinear as epl
7 |
8 |
9 | def test_optimization_price() -> None:
10 | """Test optimization of the renewable generator for price."""
11 | electricity_prices = [-100.0, -1, 1, 100]
12 |
13 | # test that we only dispatch the asset when prices are positive
14 | expected = [0.0, 0.0, 50, 50]
15 | asset = epl.RenewableGenerator(
16 | electric_generation_mwh=[50, 50, 50, 50],
17 | name="wind",
18 | electric_generation_lower_bound_pct=0.0,
19 | electricity_prices=electricity_prices,
20 | )
21 | simulation = asset.optimize()
22 | results = simulation.results
23 | np.testing.assert_array_equal(results["site-export_power_mwh"], expected)
24 | np.testing.assert_array_equal(results["wind-electric_generation_mwh"], expected)
25 |
26 | # test when we only allow 50% turndown
27 | expected = [25, 25, 50, 50]
28 | asset = epl.RenewableGenerator(
29 | electric_generation_mwh=[50, 50, 50, 50],
30 | name="wind",
31 | electric_generation_lower_bound_pct=0.5,
32 | electricity_prices=electricity_prices,
33 | )
34 | simulation = asset.optimize()
35 | results = simulation.results
36 | np.testing.assert_array_equal(results["site-export_power_mwh"], expected)
37 | np.testing.assert_array_equal(results["wind-electric_generation_mwh"], expected)
38 |
39 | # test that when we disallow the asset to turn down, it always generates
40 | expected = [50, 50, 50, 50]
41 | asset = epl.RenewableGenerator(
42 | electric_generation_mwh=[50, 50, 50, 50],
43 | name="wind",
44 | electric_generation_lower_bound_pct=1.0,
45 | electricity_prices=electricity_prices,
46 | )
47 | simulation = asset.optimize()
48 | results = simulation.results
49 | np.testing.assert_array_equal(results["site-export_power_mwh"], expected)
50 | np.testing.assert_array_equal(results["wind-electric_generation_mwh"], expected)
51 |
52 |
53 | def test_optimization_carbon() -> None:
54 | """Test optimization of the renewable generator for carbon."""
55 | electricity_carbon_intensities = [-1.0, 0.1, 1.0]
56 |
57 | # test when we allow asset to turn down
58 | expected = [0.0, 20, 30]
59 | asset = epl.RenewableGenerator(
60 | electric_generation_mwh=[10, 20, 30, 40],
61 | name="wind",
62 | electric_generation_lower_bound_pct=0.0,
63 | electricity_carbon_intensities=electricity_carbon_intensities,
64 | )
65 | simulation = asset.optimize(objective="carbon")
66 | results = simulation.results
67 |
68 | assert results.shape[0] == 3
69 | np.testing.assert_array_equal(results["site-export_power_mwh"], expected)
70 | np.testing.assert_array_equal(results["wind-electric_generation_mwh"], expected)
71 |
72 | # test when we don't allow asset to turn down
73 | expected = [10.0, 20, 30]
74 | asset = epl.RenewableGenerator(
75 | electric_generation_mwh=[10, 20, 30, 40],
76 | name="wind",
77 | electric_generation_lower_bound_pct=1.0,
78 | electricity_carbon_intensities=electricity_carbon_intensities,
79 | )
80 | simulation = asset.optimize(objective="carbon")
81 | results = simulation.results
82 |
83 | assert results.shape[0] == 3
84 | np.testing.assert_array_equal(results["site-export_power_mwh"], expected)
85 | np.testing.assert_array_equal(results["wind-electric_generation_mwh"], expected)
86 |
87 |
88 | def test_interval_data() -> None:
89 | """Tests the epl.RenewableGenerator and epl.Site interval data."""
90 |
91 | # the happy paths
92 | epl.assets.renewable_generator.RenewableGeneratorIntervalData(
93 | electric_generation_mwh=[1.0, 2.0]
94 | )
95 | epl.assets.renewable_generator.RenewableGeneratorIntervalData(
96 | electric_generation_mwh=np.array([1.0, 2.0])
97 | )
98 |
99 | # test that we transform a float into a list
100 | # this is so the repeating to length will work correctly in epl.Site
101 | idata = epl.assets.renewable_generator.RenewableGeneratorIntervalData(
102 | electric_generation_mwh=2.0
103 | )
104 | assert idata.electric_generation_mwh == [2.0]
105 |
106 | # test that we fail with no data
107 | with pytest.raises(Exception):
108 | epl.assets.renewable_generator.RenewableGeneratorIntervalData() # type: ignore
109 |
110 | # test that we fail with negative values
111 | with pytest.raises(Exception):
112 | epl.assets.renewable_generator.RenewableGeneratorIntervalData(
113 | electric_generation_mwh=[-10, 10]
114 | )
115 |
116 |
117 | @hypothesis.settings(print_blob=True, deadline=None)
118 | @hypothesis.given(
119 | idx_length=hypothesis.strategies.integers(min_value=10, max_value=24),
120 | prices_mu=hypothesis.strategies.floats(min_value=-1000, max_value=1000),
121 | prices_std=hypothesis.strategies.floats(min_value=0.1, max_value=1000),
122 | prices_offset=hypothesis.strategies.floats(min_value=-250, max_value=250),
123 | electric_generation_lower_bound_pct=hypothesis.strategies.floats(
124 | min_value=0, max_value=1.0
125 | ),
126 | include_spill=hypothesis.strategies.booleans(),
127 | )
128 | def test_hypothesis(
129 | idx_length: int,
130 | prices_mu: float,
131 | prices_std: float,
132 | prices_offset: float,
133 | electric_generation_lower_bound_pct: float,
134 | include_spill: bool,
135 | ) -> None:
136 | """Test optimization with hypothesis."""
137 | electricity_prices = (
138 | np.random.normal(prices_mu, prices_std, idx_length) + prices_offset
139 | )
140 | electric_generation_mwh = np.clip(
141 | np.random.normal(prices_mu, prices_std, idx_length) + prices_offset,
142 | a_min=0,
143 | a_max=None,
144 | )
145 | asset = epl.RenewableGenerator(
146 | electricity_prices=electricity_prices,
147 | electric_generation_mwh=electric_generation_mwh,
148 | electric_generation_lower_bound_pct=electric_generation_lower_bound_pct,
149 | include_spill=include_spill,
150 | )
151 | asset.optimize(verbose=False)
152 |
--------------------------------------------------------------------------------
/tests/common.py:
--------------------------------------------------------------------------------
1 | """Common utilities for testing."""
2 | import numpy as np
3 |
4 | import energypylinear as epl
5 |
6 | asset_names = ["battery", "evs", "chp", "heat-pump", "renewable"]
7 |
8 |
9 | def get_assets(ds: dict, asset: str) -> list[epl.OptimizableAsset]:
10 | """Helper function to get assets from a string."""
11 | assets: list = []
12 | library = {
13 | "battery": epl.Battery(
14 | power_mw=2,
15 | capacity_mwh=4,
16 | efficiency_pct=0.9,
17 | **ds,
18 | ),
19 | "evs": epl.EVs(**ds, charger_turndown=0.0, charge_event_efficiency=1.0),
20 | "chp": epl.CHP(
21 | electric_power_max_mw=100,
22 | electric_power_min_mw=50,
23 | electric_efficiency_pct=0.2,
24 | high_temperature_efficiency_pct=0.2,
25 | low_temperature_efficiency_pct=0.2,
26 | **ds,
27 | ),
28 | "heat-pump": epl.HeatPump(
29 | **ds,
30 | ),
31 | "renewable": epl.RenewableGenerator(
32 | electric_generation_mwh=np.random.uniform(
33 | 0, 100, len(ds["electricity_prices"])
34 | ),
35 | **ds,
36 | ),
37 | }
38 | assets.append(library[asset])
39 | return assets
40 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Module for configuring Pytest with custom logger settings.
2 |
3 | This module allows users to disable specific loggers when running pytest.
4 | """
5 |
6 | import logging
7 | import os
8 |
9 | import pandas as pd
10 | import pytest
11 |
12 |
13 | @pytest.fixture(autouse=True)
14 | def set_pandas_options() -> None:
15 | """Forces pandas to print all columns on one line."""
16 | pd.set_option("display.max_columns", 24)
17 | pd.set_option("display.width", 1000)
18 |
19 |
20 | def pytest_configure() -> None:
21 | """Disable specific loggers during pytest runs.
22 |
23 | Loggers to be disabled can be set using the DISABLE_LOGGERS environment variable.
24 |
25 | By default, it disables the logger "energypylinear.optimizer".
26 | """
27 | disable_logger_names = os.getenv("DISABLE_LOGGERS")
28 |
29 | if disable_logger_names is None:
30 | disable_loggers = ["energypylinear.optimizer"]
31 | else:
32 | disable_loggers = disable_logger_names.split(",")
33 |
34 | for logger_name in disable_loggers:
35 | logger = logging.getLogger(logger_name)
36 | logger.disabled = True
37 |
--------------------------------------------------------------------------------
/tests/generate-test-docs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | generate_tests() {
4 | local input_path=$1
5 | local output_dir="tests/phmdoctest"
6 | local base=$(basename "$input_path" .md)
7 | local output_file="$output_dir/test_${base}.py"
8 |
9 | echo "Generating test for $input_path to $output_file"
10 | python -m phmdoctest "$input_path" --outfile "$output_file"
11 | }
12 |
13 | echo "Removing Previous Tests"
14 | rm -rf ./tests/phmdoctest
15 | mkdir ./tests/phmdoctest
16 |
17 | echo "Processing README.md"
18 | generate_tests "README.md"
19 |
20 | echo "Processing Markdown files in ./docs/docs"
21 | find ./docs/docs -name "*.md" -print0 | while IFS= read -r -d '' file; do
22 | generate_tests "$file"
23 | done
24 |
25 | echo "Don't Test Changelog"
26 | rm ./tests/phmdoctest/test_changelog.py
27 |
--------------------------------------------------------------------------------
/tests/test_accounting.py:
--------------------------------------------------------------------------------
1 | """Tests for `epl.accounting`."""
2 | import pandas as pd
3 | import pytest
4 |
5 | import energypylinear as epl
6 | from energypylinear.defaults import defaults
7 |
8 |
9 | def test_accounting_actuals() -> None:
10 | """Check calculation of electricity and gas costs and emissions."""
11 |
12 | results = pd.DataFrame(
13 | {
14 | "site-import_power_mwh": [100, 50, 0],
15 | "site-export_power_mwh": [0, 0, 20],
16 | "total-gas_consumption_mwh": [20, 30, 40],
17 | "total-electric_generation_mwh": [20, 30, 40],
18 | "total-electric_load_mwh": [120, 80, 20],
19 | "total-high_temperature_generation_mwh": [0, 0, 0],
20 | "total-high_temperature_load_mwh": [0, 0, 0],
21 | "total-low_temperature_generation_mwh": [0, 0, 0],
22 | "total-low_temperature_load_mwh": [0, 0, 0],
23 | "total-electric_charge_mwh": [0, 0, 0],
24 | "total-electric_discharge_mwh": [0, 0, 0],
25 | "total-electric_loss_mwh": [0, 0, 0],
26 | "total-spills_mwh": [0, 0, 0],
27 | "load-high_temperature_load_mwh": [0, 0, 0],
28 | "load-low_temperature_load_mwh": [0, 0, 0],
29 | "load-low_temperature_generation_mwh": [0, 0, 0],
30 | "site-electricity_prices": [100, 200, -300],
31 | "site-gas_prices": 15,
32 | "site-electricity_carbon_intensities": 0.5,
33 | }
34 | )
35 | actuals = epl.accounting.get_accounts(results, validate=True)
36 |
37 | assert actuals.electricity.import_cost == 100 * 100 + 200 * 50
38 | assert actuals.electricity.export_cost == -20 * -300
39 | assert actuals.electricity.cost == 100 * 100 + 50 * 200 - 20 * -300
40 |
41 | assert actuals.electricity.import_emissions == 0.5 * (100 + 50)
42 | assert actuals.electricity.export_emissions == -0.5 * (20)
43 | assert actuals.electricity.emissions == 0.5 * (100 + 50 - 20)
44 |
45 | assert actuals.gas.cost == 15 * (20 + 30 + 40)
46 | assert actuals.gas.emissions == defaults.gas_carbon_intensity * (20 + 30 + 40)
47 |
48 | assert actuals.emissions == 0.5 * (
49 | 100 + 50 - 20
50 | ) + defaults.gas_carbon_intensity * (20 + 30 + 40)
51 | assert actuals.cost == 100 * 100 + 50 * 200 - 20 * -300 + 15 * (20 + 30 + 40)
52 |
53 | variance = actuals - actuals
54 | assert variance.cost == 0
55 | assert variance.emissions == 0
56 |
57 | # randomly thrown in here for coverage
58 | with pytest.raises(NotImplementedError):
59 | actuals - float(32)
60 |
61 |
62 | def test_accounting_forecasts() -> None:
63 | """Check calculation of forecast electricity and gas costs and emissions."""
64 | results = pd.DataFrame(
65 | {
66 | "site-import_power_mwh": [100, 50, 0],
67 | "site-export_power_mwh": [0, 0, 20],
68 | "total-gas_consumption_mwh": [20, 30, 40],
69 | "total-electric_generation_mwh": [20, 30, 40],
70 | "total-electric_load_mwh": [20, 30, 40],
71 | }
72 | )
73 | price_results_actuals = pd.DataFrame(
74 | {
75 | "site-electricity_prices": [100, 200, -300],
76 | "site-gas_prices": 15,
77 | "site-electricity_carbon_intensities": 0.5,
78 | }
79 | )
80 | price_results_forecasts = pd.DataFrame(
81 | {
82 | "site-electricity_prices": [200, -100, 100],
83 | "site-gas_prices": 10,
84 | "site-electricity_carbon_intensities": 0.4,
85 | }
86 | )
87 | actuals = epl.accounting.get_accounts(
88 | results, price_results_actuals, validate=False
89 | )
90 | forecasts = epl.accounting.get_accounts(
91 | results, price_results_forecasts, validate=False
92 | )
93 |
94 | assert forecasts.electricity.import_cost == 200 * 100 + -100 * 50
95 | assert forecasts.electricity.export_cost == -20 * 100
96 | assert forecasts.electricity.cost == 200 * 100 + -100 * 50 - 20 * 100
97 |
98 | assert forecasts.electricity.import_emissions == 0.4 * (100 + 50)
99 | assert forecasts.electricity.export_emissions == -0.4 * (20)
100 | assert forecasts.electricity.emissions == 0.4 * (100 + 50 - 20)
101 |
102 | assert forecasts.gas.cost == 10 * (20 + 30 + 40)
103 | assert forecasts.gas.emissions == defaults.gas_carbon_intensity * (20 + 30 + 40)
104 |
105 | assert forecasts.emissions == 0.4 * (
106 | 100 + 50 - 20
107 | ) + defaults.gas_carbon_intensity * (20 + 30 + 40)
108 | assert forecasts.cost == 200 * 100 + -100 * 50 - 20 * 100 + 10 * (20 + 30 + 40)
109 |
110 | variance = actuals - forecasts
111 | assert variance.cost == actuals.cost - forecasts.cost
112 | assert variance.emissions == actuals.emissions - forecasts.emissions
113 |
--------------------------------------------------------------------------------
/tests/test_constraints.py:
--------------------------------------------------------------------------------
1 | """Tests the constraints on minimum and maximum variables."""
2 | import random
3 |
4 | import hypothesis
5 | import numpy as np
6 | import pulp
7 | import pytest
8 |
9 | import energypylinear as epl
10 |
11 | atol = 1e-4
12 | settings = hypothesis.settings(
13 | print_blob=True, max_examples=1000, report_multiple_bugs=False, deadline=None
14 | )
15 |
16 |
17 | def coerce_variables(
18 | a: float,
19 | b: float,
20 | a_gap: float,
21 | b_gap: float,
22 | a_is_float: bool,
23 | b_is_float: bool,
24 | opt: "epl.Optimizer",
25 | ) -> tuple[float | pulp.LpVariable, float | pulp.LpVariable]:
26 | """Helper function to transform hypothesis parameters.
27 |
28 | a_is_float | b_is_float | av | bv
29 | True | True | float | float
30 | True | False | float | lpvar
31 | False | True | lpvar | float
32 | False | False | lpvar | lpvar
33 | """
34 | if a_is_float:
35 | av = a
36 | else:
37 | av = opt.continuous("a", low=a, up=a + a_gap)
38 |
39 | if b_is_float and not a_is_float:
40 | bv = b
41 | else:
42 | bv = opt.continuous("b", low=b, up=b + b_gap)
43 |
44 | if isinstance(av, float):
45 | assert isinstance(bv, pulp.LpVariable)
46 | if isinstance(bv, float):
47 | assert isinstance(av, pulp.LpVariable)
48 |
49 | return av, bv
50 |
51 |
52 | @pytest.mark.parametrize(
53 | "a_is_float, b_is_float, expected_av_type, expected_bv_type",
54 | [
55 | (True, True, "float", "lpvar"),
56 | (True, False, "float", "lpvar"),
57 | (False, True, "lpvar", "float"),
58 | (False, False, "lpvar", "lpvar"),
59 | ],
60 | )
61 | def test_coerce_variables(
62 | a_is_float: bool, b_is_float: bool, expected_av_type: str, expected_bv_type: str
63 | ) -> None:
64 | """Test the coerce variables helper.
65 |
66 | Args:
67 | a_is_float: Whether a is a float or LpVariable.
68 | b_is_float: Whether b is a float or LpVariable.
69 | expected_av_type: Expected type of av.
70 | expected_bv_type: Expected type of bv.
71 | """
72 | a, b, a_gap, b_gap = 1.0, 2.0, 0.5, 0.5
73 | opt = epl.Optimizer()
74 | av, bv = coerce_variables(a, b, a_gap, b_gap, a_is_float, b_is_float, opt)
75 |
76 | if expected_av_type == "float":
77 | assert isinstance(av, float)
78 | else:
79 | assert isinstance(av, pulp.LpVariable)
80 |
81 | if expected_bv_type == "float":
82 | assert isinstance(bv, float)
83 | else:
84 | assert isinstance(bv, pulp.LpVariable)
85 |
86 |
87 | @hypothesis.settings(settings)
88 | @hypothesis.given(
89 | a=hypothesis.strategies.floats(
90 | allow_infinity=False, allow_nan=False, min_value=0, max_value=1000
91 | ),
92 | b=hypothesis.strategies.floats(
93 | allow_infinity=False, allow_nan=False, min_value=0, max_value=1000
94 | ),
95 | a_gap=hypothesis.strategies.floats(
96 | allow_infinity=False, allow_nan=False, min_value=0.1, max_value=1000
97 | ),
98 | b_gap=hypothesis.strategies.floats(
99 | allow_infinity=False, allow_nan=False, min_value=0.1, max_value=1000
100 | ),
101 | a_is_float=hypothesis.strategies.booleans(),
102 | b_is_float=hypothesis.strategies.booleans(),
103 | )
104 | def test_min_two_variables(
105 | a: float, b: float, a_gap: float, b_gap: float, a_is_float: bool, b_is_float: bool
106 | ) -> None:
107 | """Tests that we can constrain a variable to be the minimum of two other variables."""
108 |
109 | opt = epl.Optimizer()
110 | av, bv = coerce_variables(a, b, a_gap, b_gap, a_is_float, b_is_float, opt)
111 | cv = opt.min_two_variables("min-a-b", av, bv, M=1000)
112 | opt.objective(av + bv)
113 | opt.solve(verbose=3)
114 | np.testing.assert_allclose(min(a, b), cv.value(), rtol=1e-2, atol=1e-2)
115 |
116 |
117 | @hypothesis.settings(settings)
118 | @hypothesis.given(
119 | a=hypothesis.strategies.floats(
120 | allow_infinity=False, allow_nan=False, min_value=0, max_value=1000
121 | ),
122 | b=hypothesis.strategies.floats(
123 | allow_infinity=False, allow_nan=False, min_value=0, max_value=1000
124 | ),
125 | a_gap=hypothesis.strategies.floats(
126 | allow_infinity=False, allow_nan=False, min_value=0.1, max_value=1000
127 | ),
128 | b_gap=hypothesis.strategies.floats(
129 | allow_infinity=False, allow_nan=False, min_value=0.1, max_value=1000
130 | ),
131 | a_is_float=hypothesis.strategies.booleans(),
132 | b_is_float=hypothesis.strategies.booleans(),
133 | )
134 | def test_max_two_variables(
135 | a: float, b: float, a_gap: float, b_gap: float, a_is_float: bool, b_is_float: bool
136 | ) -> None:
137 | """Tests that we can constrain a variable to be the maximum of two other variables."""
138 | opt = epl.Optimizer()
139 | av, bv = coerce_variables(a, b, a_gap, b_gap, a_is_float, b_is_float, opt)
140 | cv = opt.max_two_variables("max-a-b", av, bv, M=1000)
141 | opt.objective(av + bv)
142 | opt.solve(verbose=3)
143 | np.testing.assert_allclose(max(a, b), cv.value(), atol=atol)
144 |
145 |
146 | @hypothesis.settings(settings)
147 | @hypothesis.given(
148 | n=hypothesis.strategies.integers(min_value=5, max_value=100),
149 | )
150 | def test_max_many_variables(n: int) -> None:
151 | """Tests that we can constrain a variable to be the maximum of many other variables."""
152 | opt = epl.Optimizer()
153 | lp_vars = [opt.continuous(name=f"v{i}", low=0) for i in range(n)]
154 | maxes = [random.random() * 100 for _ in lp_vars]
155 | for m, v in zip(maxes, lp_vars):
156 | opt.constrain(v == m)
157 |
158 | constant = random.random() * 100
159 | maxes.append(constant)
160 | lp_vars.append(constant)
161 |
162 | max_var = opt.max_many_variables("max_many", lp_vars, M=max(maxes))
163 | opt.objective(opt.sum(lp_vars))
164 | opt.solve(verbose=0)
165 | np.testing.assert_allclose(max(maxes), max_var.value(), atol=atol)
166 |
167 |
168 | @hypothesis.settings(settings)
169 | @hypothesis.given(
170 | n=hypothesis.strategies.integers(min_value=5, max_value=100),
171 | )
172 | def test_min_many_variables(n: int) -> None:
173 | """Tests that we can constrain a variable to be the minimum of many other variables."""
174 | opt = epl.Optimizer()
175 | lp_vars = [opt.continuous(name=f"v{i}", low=0) for i in range(n)]
176 | mins = [random.random() * 100 for _ in lp_vars]
177 | for m, v in zip(mins, lp_vars):
178 | opt.constrain(v == m)
179 |
180 | constant = random.random() * 100
181 | mins.append(constant)
182 | lp_vars.append(constant)
183 |
184 | min_var = opt.min_many_variables("min_many", lp_vars, M=max(mins) * 100)
185 | opt.objective(opt.sum(lp_vars))
186 | opt.solve(verbose=0)
187 | np.testing.assert_allclose(min(mins), min_var.value(), atol=atol)
188 |
--------------------------------------------------------------------------------
/tests/test_debug.py:
--------------------------------------------------------------------------------
1 | """Tests the debugging tools."""
2 |
3 | import energypylinear as epl
4 | from energypylinear.debug import debug_asset, debug_balances, debug_simulation
5 |
6 |
7 | def test_debug() -> None:
8 | """Tests that the debugging tools work correctly."""
9 | asset = epl.Battery(
10 | electricity_prices=[100, 100],
11 | )
12 | simulation = asset.optimize(
13 | verbose=True,
14 | )
15 |
16 | debug_simulation(simulation.results)
17 | debug_balances(simulation.results)
18 | debug_asset(simulation.results, asset.cfg.name, True)
19 |
--------------------------------------------------------------------------------
/tests/test_extra_interval_data.py:
--------------------------------------------------------------------------------
1 | """Test that we can use custom interval data."""
2 | import numpy as np
3 | import pytest
4 |
5 | import energypylinear as epl
6 | from energypylinear.data_generation import generate_random_ev_input_data
7 | from energypylinear.defaults import defaults
8 | from tests.test_custom_objectives import asset_names, get_assets
9 |
10 |
11 | def test_get_custom_interval_data() -> None:
12 | """Test that we can pass in and use custom interval data with a Site."""
13 |
14 | # TODO - should it be custom or custom...???
15 |
16 | site = epl.Site(
17 | assets=[
18 | epl.Battery(
19 | power_mw=2, capacity_mwh=4, efficiency_pct=0.9, name="small-battery"
20 | ),
21 | epl.CHP(
22 | electric_power_max_mw=50,
23 | electric_efficiency_pct=0.4,
24 | high_temperature_efficiency_pct=0.4,
25 | name="gas-engine-chp",
26 | ),
27 | epl.Boiler(high_temperature_generation_max_mw=100),
28 | epl.Spill(),
29 | epl.Valve(),
30 | ],
31 | electricity_prices=[100, 1000, -20, 40, 45],
32 | network_charge=[0, 300, 300, 0, 0],
33 | interval_data=10,
34 | not_interval_data="hello",
35 | )
36 | assert hasattr(site.cfg.interval_data, "network_charge")
37 | assert not hasattr(site.cfg.interval_data, "not_interval_data")
38 |
39 | # TODO - should raise error with the not_interval_data="hello" - an custom kwarg we cannot process
40 |
41 | objective = {
42 | "terms": [
43 | {
44 | "asset_type": "site",
45 | "variable": "import_power_mwh",
46 | "interval_data": "electricity_prices",
47 | },
48 | {
49 | "asset_type": "site",
50 | "variable": "export_power_mwh",
51 | "interval_data": "electricity_prices",
52 | "coefficient": -1,
53 | },
54 | {
55 | "asset_type": "*",
56 | "variable": "gas_consumption_mwh",
57 | "interval_data": "gas_prices",
58 | },
59 | {
60 | "asset_type": "site",
61 | "variable": "import_power_mwh",
62 | # here we use the custom / custom interval data
63 | "interval_data": "network_charge",
64 | },
65 | ]
66 | }
67 | sim = site.optimize(objective)
68 | assert "site-network_charge" in sim.results.columns
69 |
70 | # below we check that the custom interval data is repeated
71 | site = epl.Site(
72 | assets=[
73 | epl.Battery(
74 | power_mw=2, capacity_mwh=4, efficiency_pct=0.9, name="small-battery"
75 | ),
76 | epl.CHP(
77 | electric_power_max_mw=50,
78 | electric_efficiency_pct=0.4,
79 | high_temperature_efficiency_pct=0.4,
80 | name="gas-engine-chp",
81 | ),
82 | epl.Boiler(high_temperature_generation_max_mw=100),
83 | epl.Spill(),
84 | epl.Valve(),
85 | ],
86 | electricity_prices=[100, 1000, -20, 40, 45],
87 | # network charge is too short, should fail - but only if we aren't trying to repeat
88 | # instead could test current behaviour, which is to always repeat...
89 | network_charge=[1, 300, 300, 0],
90 | )
91 | sim = site.optimize(objective)
92 | assert sim.results["site-network_charge"].tolist() == [1, 300, 300, 0, 1]
93 |
94 | # TODO - check we fail if we try to use custom interval data that isn't passed into the site init
95 |
96 |
97 | @pytest.mark.parametrize("asset_name", asset_names)
98 | def test_get_custom_interval_data_assets(asset_name: str) -> None:
99 | """Test that we can pass in and use custom interval data with all the assets."""
100 | ds = generate_random_ev_input_data(48, n_chargers=3, charge_length=3, seed=None)
101 |
102 | ds["network_charge"] = np.zeros_like(ds["electricity_prices"])
103 |
104 | # TODO - should just return a dict
105 | assets = get_assets(ds, asset_name)
106 | assert len(assets) == 1
107 | asset = assets[0]
108 | assert asset.site is not None
109 | assert hasattr(asset.site.cfg.interval_data, "network_charge")
110 |
111 | objective = [
112 | {
113 | "asset_type": "site",
114 | "variable": "import_power_mwh",
115 | "interval_data": "electricity_prices",
116 | },
117 | {
118 | "asset_type": "site",
119 | "variable": "export_power_mwh",
120 | "interval_data": "electricity_prices",
121 | "coefficient": -1,
122 | },
123 | {
124 | "asset_type": "*",
125 | "variable": "gas_consumption_mwh",
126 | "interval_data": "gas_prices",
127 | },
128 | {
129 | "asset_type": "site",
130 | "variable": "export_power_mwh",
131 | "interval_data": "network_charge",
132 | "coefficient": -1000,
133 | },
134 | ]
135 |
136 | objective.extend(
137 | [
138 | {
139 | "asset_type": "spill",
140 | "variable": variable,
141 | "coefficient": defaults.spill_objective_penalty,
142 | }
143 | for variable in [
144 | "electric_generation_mwh",
145 | "high_temperature_generation_mwh",
146 | "electric_load_mwh",
147 | "electric_charge_mwh",
148 | "electric_discharge_mwh",
149 | ]
150 | ]
151 | )
152 | objective.extend(
153 | [
154 | {
155 | "asset_type": "spill_evs",
156 | "variable": variable,
157 | "coefficient": defaults.spill_objective_penalty,
158 | }
159 | for variable in [
160 | "electric_generation_mwh",
161 | "high_temperature_generation_mwh",
162 | "electric_load_mwh",
163 | "electric_charge_mwh",
164 | "electric_discharge_mwh",
165 | ]
166 | ],
167 | )
168 | sim = asset.optimize({"terms": objective})
169 | assert "site-network_charge" in sim.results.columns
170 |
171 | # TODO - check the export power - should be at site limit...
172 | # but its a bit trikcy with the different assets...
173 |
--------------------------------------------------------------------------------
/tests/test_flags.py:
--------------------------------------------------------------------------------
1 | """Test use of boolean flags to change sim and results behaviour."""
2 | import numpy as np
3 |
4 | import energypylinear as epl
5 | from energypylinear.flags import Flags
6 |
7 |
8 | def test_flags() -> None:
9 | """Test that we can correctly use flags in our assets."""
10 |
11 | asset = epl.Battery(
12 | power_mw=2.0,
13 | capacity_mwh=4.0,
14 | electricity_prices=np.random.normal(10, 5, 100),
15 | )
16 | asset.optimize(
17 | flags=Flags(),
18 | )
19 |
20 | ds = epl.data_generation.generate_random_ev_input_data(
21 | 48, n_chargers=3, charge_length=3, n_charge_events=12, seed=42
22 | )
23 | evs = epl.EVs(**ds)
24 | evs.optimize(
25 | flags=Flags(limit_charge_variables_to_valid_events=True),
26 | )
27 | evs.optimize(
28 | flags=Flags(limit_charge_variables_to_valid_events=False),
29 | )
30 |
--------------------------------------------------------------------------------
/tests/test_freq.py:
--------------------------------------------------------------------------------
1 | """Tests for `epl.freq`."""
2 | import energypylinear as epl
3 |
4 |
5 | def test_freq() -> None:
6 | """Check our power and energy conversions are correct."""
7 |
8 | freq = epl.freq.Freq(60)
9 | assert 100 == freq.mw_to_mwh(100)
10 | assert 100 == freq.mwh_to_mw(100)
11 |
12 | freq = epl.freq.Freq(30)
13 | assert 50 == freq.mw_to_mwh(100)
14 | assert 200 == freq.mwh_to_mw(100)
15 |
16 | freq = epl.freq.Freq(5)
17 | assert 200 / 12 == freq.mw_to_mwh(200)
18 | assert 200 * 12 == freq.mwh_to_mw(200)
19 |
20 | # test the repr
21 | print(freq)
22 | repr(freq)
23 |
--------------------------------------------------------------------------------
/tests/test_interval_vars.py:
--------------------------------------------------------------------------------
1 | """Interval variable testing."""
2 |
3 | import energypylinear as epl
4 | from energypylinear.interval_data import IntervalVars
5 |
6 |
7 | def test_interval_vars() -> None:
8 | """Tests the `epl.IntervalVars` object."""
9 | ds = epl.data_generation.generate_random_ev_input_data(
10 | 48, n_chargers=3, charge_length=3, n_charge_events=12, seed=42
11 | )
12 | asset = epl.EVs(**ds)
13 | asset_two = epl.EVs(**ds, name="evs-two")
14 | optimizer = epl.Optimizer()
15 |
16 | evs = asset.one_interval(optimizer, i=0, freq=epl.Freq(60))
17 | evs_two = asset_two.one_interval(optimizer, i=0, freq=epl.Freq(60))
18 | site = asset.site.one_interval(
19 | optimizer, asset.site.cfg, i=0, freq=epl.freq.Freq(60)
20 | )
21 |
22 | ivars = IntervalVars()
23 | ivars.append([site, *evs, *evs_two])
24 |
25 | assert (
26 | len(
27 | ivars.filter_objective_variables(
28 | instance_type=epl.assets.evs.EVOneInterval, i=0
29 | )
30 | )
31 | == 3 * 12 * 2
32 | )
33 |
34 | assert (
35 | len(
36 | ivars.filter_objective_variables(
37 | instance_type=epl.assets.evs.EVSpillOneInterval, i=0
38 | )
39 | )
40 | == 1 * 12 * 2
41 | )
42 |
43 | assert (
44 | len(
45 | ivars.filter_objective_variables(
46 | instance_type=epl.assets.evs.EVOneInterval,
47 | i=0,
48 | asset_name=asset.cfg.name,
49 | )
50 | )
51 | == 3 * 12
52 | )
53 | ivars[0]
54 | ivars[-1]
55 |
56 | # test that we can call `filter_objective_variables` with different strings
57 | for asset_name in [
58 | "battery",
59 | "evs",
60 | "chp",
61 | "heat-pump",
62 | "renewable-generator",
63 | "boiler",
64 | ]:
65 | ivars.filter_objective_variables(instance_type=asset_name, i=0)
66 |
--------------------------------------------------------------------------------
/tests/test_network_charges.py:
--------------------------------------------------------------------------------
1 | """Test that we can simulate network charges.
2 |
3 | Should belong in the custom constraints tests really.
4 | """
5 |
6 | import numpy as np
7 |
8 | import energypylinear as epl
9 |
10 |
11 | def test_network_charges() -> None:
12 | """Test that we can simulate a network charge using extra interval data."""
13 | # first test nothing happens with no network charge
14 | site = epl.Site(
15 | assets=[
16 | epl.CHP(
17 | electric_power_max_mw=200,
18 | electric_efficiency_pct=1.0,
19 | name="chp",
20 | ),
21 | ],
22 | electricity_prices=np.zeros(5),
23 | network_charge=[0, 0, 0, 0, 0],
24 | electric_load_mwh=100,
25 | )
26 |
27 | objective = {
28 | "terms": [
29 | {
30 | "asset_type": "site",
31 | "variable": "import_power_mwh",
32 | "interval_data": "electricity_prices",
33 | },
34 | {
35 | "asset_type": "site",
36 | "variable": "export_power_mwh",
37 | "interval_data": "electricity_prices",
38 | "coefficient": -1,
39 | },
40 | {
41 | "asset_type": "*",
42 | "variable": "gas_consumption_mwh",
43 | "interval_data": "gas_prices",
44 | },
45 | {
46 | "asset_type": "site",
47 | "variable": "import_power_mwh",
48 | "interval_data": "network_charge",
49 | "coefficient": 1000,
50 | },
51 | ]
52 | }
53 | sim = site.optimize(objective, verbose=0)
54 | assert all(sim.results["site-import_power_mwh"] == np.full(5, 100))
55 | assert all(sim.results["site-export_power_mwh"] == np.zeros(5))
56 | assert all(sim.results["chp-electric_generation_mwh"] == np.zeros(5))
57 |
58 | # now change the network charge
59 | # expect that we fire the generator
60 | site = epl.Site(
61 | assets=[
62 | epl.CHP(
63 | electric_power_max_mw=200,
64 | electric_efficiency_pct=1.0,
65 | name="chp",
66 | ),
67 | ],
68 | electricity_prices=np.zeros(5),
69 | network_charge=[0, 300, 0, 0, 0],
70 | electric_load_mwh=100,
71 | )
72 | sim = site.optimize(objective, verbose=0)
73 | assert all(sim.results["site-import_power_mwh"] == [100, 0, 100, 100, 100])
74 | assert all(sim.results["site-export_power_mwh"] == np.zeros(5))
75 | assert all(sim.results["chp-electric_generation_mwh"] == [0, 100, 0, 0, 0])
76 |
--------------------------------------------------------------------------------
/tests/test_optimizer.py:
--------------------------------------------------------------------------------
1 | """Tests the configuration of the optimizer."""
2 |
3 |
4 | import numpy as np
5 | import pytest
6 |
7 | import energypylinear as epl
8 |
9 |
10 | def test_optimizer_config() -> None:
11 | """Test the use of the optimizer config.
12 |
13 | TODO - pulp will still report Optimal status after the timeout, even if the
14 | relative gap isn't reached.
15 |
16 | Looks like the only way to sort this would be to capture the logs.
17 | """
18 |
19 | opt_cfg = epl.OptimizerConfig(timeout=2, relative_tolerance=0.0, verbose=True)
20 | electricity_prices = np.clip(np.random.normal(100, 1000, 512), a_min=0, a_max=None)
21 | export_electricity_prices = 20.0
22 | asset = epl.Battery(
23 | electricity_prices=electricity_prices,
24 | export_electricity_prices=export_electricity_prices,
25 | )
26 | asset.optimize(
27 | optimizer_config=opt_cfg,
28 | )
29 | asset.optimize(optimizer_config={"timeout": 2})
30 | asset.site.optimizer.cfg.dict()
31 |
32 | with pytest.raises(TypeError):
33 | asset.optimize(optimizer_config={"timou": 2})
34 |
35 | asset.site.optimizer.variables(dict=True)
36 |
--------------------------------------------------------------------------------
/tests/test_plot.py:
--------------------------------------------------------------------------------
1 | """Tests for `epl.plots`.
2 |
3 | These tests don't test the plot contents - only that we can plot & save something.
4 | """
5 | import numpy as np
6 | import pytest
7 |
8 | import energypylinear as epl
9 | from energypylinear.flags import Flags
10 |
11 |
12 | def test_battery_plot(tmp_path_factory: pytest.TempPathFactory) -> None:
13 | """Test we can plot the battery chart."""
14 | path = tmp_path_factory.mktemp("figs")
15 | asset = epl.Battery(
16 | power_mw=2, capacity_mwh=4, electricity_prices=np.random.normal(100, 10, 10)
17 | )
18 | simulation = asset.optimize()
19 |
20 | assert not (path / "battery.png").exists()
21 | asset.plot(simulation, path=path)
22 | assert (path / "battery.png").exists()
23 | asset.plot(simulation, path=path / "battery-custom.png")
24 | assert (path / "battery-custom.png").exists()
25 |
26 |
27 | def test_evs_plot(tmp_path_factory: pytest.TempPathFactory) -> None:
28 | """Test we can plot the EVs chart."""
29 | path = tmp_path_factory.mktemp("figs")
30 | ds = epl.data_generation.generate_random_ev_input_data(10, 5, 3)
31 | asset = epl.EVs(
32 | **ds,
33 | charge_event_efficiency=1.0,
34 | charger_turndown=0.0,
35 | )
36 | results = asset.optimize(
37 | flags=Flags(
38 | allow_evs_discharge=False,
39 | fail_on_spill_asset_use=True,
40 | allow_infeasible=False,
41 | ),
42 | )
43 | assert not (path / "evs.png").exists()
44 | asset.plot(results, path=path)
45 | assert (path / "evs.png").exists()
46 | asset.plot(results, path=path / "evs-custom.png")
47 | assert (path / "evs-custom.png").exists()
48 |
49 |
50 | def test_chp_plot(tmp_path_factory: pytest.TempPathFactory) -> None:
51 | """Test we can plot the CHP chart."""
52 | path = tmp_path_factory.mktemp("figs")
53 |
54 | prices = np.random.uniform(-1000, 1000, 24).tolist()
55 | ht_load = np.random.uniform(0, 100, 24).tolist()
56 | lt_load = np.random.uniform(0, 100, 24).tolist()
57 |
58 | asset = epl.CHP(
59 | electric_power_max_mw=100,
60 | electric_power_min_mw=50,
61 | electric_efficiency_pct=0.3,
62 | high_temperature_efficiency_pct=0.5,
63 | electricity_prices=prices,
64 | gas_prices=20,
65 | high_temperature_load_mwh=ht_load,
66 | low_temperature_load_mwh=lt_load,
67 | freq_mins=60,
68 | include_spill=True,
69 | )
70 | results = asset.optimize()
71 |
72 | assert not (path / "chp.png").exists()
73 | asset.plot(results, path=path)
74 | assert (path / "chp.png").exists()
75 | asset.plot(results, path=path / "chp-custom.png")
76 | assert (path / "chp-custom.png").exists()
77 |
78 |
79 | def test_heat_pump_plot(tmp_path_factory: pytest.TempPathFactory) -> None:
80 | """Test we can plot the CHP chart."""
81 | path = tmp_path_factory.mktemp("figs")
82 |
83 | prices = np.random.uniform(-1000, 1000, 24).tolist()
84 | ht_load = np.random.uniform(0, 100, 24).tolist()
85 | lt_load = np.random.uniform(0, 100, 24).tolist()
86 | lt_gen = np.random.uniform(0, 100, 24).tolist()
87 |
88 | asset = epl.HeatPump(
89 | 10,
90 | 2.0,
91 | electricity_prices=prices,
92 | gas_prices=20,
93 | high_temperature_load_mwh=ht_load,
94 | low_temperature_load_mwh=lt_load,
95 | low_temperature_generation_mwh=lt_gen,
96 | freq_mins=60,
97 | include_spill=True,
98 | )
99 | results = asset.optimize()
100 |
101 | assert not (path / "heat-pump.png").exists()
102 | asset.plot(results, path=path)
103 | assert (path / "heat-pump.png").exists()
104 |
--------------------------------------------------------------------------------
/tests/test_repr.py:
--------------------------------------------------------------------------------
1 | """Keeping coverage happy by testing __repr__ with print."""
2 | import numpy as np
3 | import pandas as pd
4 |
5 | import energypylinear as epl
6 |
7 |
8 | def test_repr() -> None:
9 | """Test we can print our things."""
10 |
11 | ds = epl.data_generation.generate_random_ev_input_data(
12 | 48, n_chargers=3, charge_length=3, n_charge_events=12, seed=42
13 | )
14 | site = epl.Site(assets=[], electricity_prices=np.array([0, 0]))
15 | things = [
16 | site,
17 | site.cfg,
18 | epl.HeatPump(electric_power_mw=1.0, cop=3),
19 | epl.Battery(),
20 | epl.CHP(),
21 | epl.Boiler(),
22 | epl.EVs(**ds),
23 | epl.RenewableGenerator(electric_generation_mwh=[10]),
24 | epl.assets.evs.EVOneInterval(
25 | i=0,
26 | charge_event_idx=0,
27 | charge_event_cfg=epl.assets.evs.ChargeEventConfig(
28 | name="ce", capacity_mwh=10, efficiency_pct=0.5
29 | ),
30 | charger_idx=0,
31 | charger_cfg=epl.assets.evs.ChargerConfig(
32 | name="ca", power_min_mw=0, power_max_mw=0
33 | ),
34 | cfg=epl.assets.evs.EVsConfig(
35 | name="evs",
36 | charger_cfgs=np.array([0]),
37 | spill_charger_cfgs=np.array([0]),
38 | charge_event_cfgs=np.array([0]),
39 | freq_mins=0,
40 | charge_events=np.array([[0], [0]]),
41 | ),
42 | initial_soc_mwh=0.0,
43 | final_soc_mwh=0.0,
44 | electric_charge_mwh=0.0,
45 | electric_charge_binary=0,
46 | electric_discharge_mwh=0.0,
47 | electric_discharge_binary=0,
48 | electric_loss_mwh=0.0,
49 | ),
50 | epl.assets.evs.EVSpillOneInterval(
51 | i=0,
52 | charge_event_idx=0,
53 | charge_event_cfg=epl.assets.evs.ChargeEventConfig(
54 | name="ce", capacity_mwh=10, efficiency_pct=0.5
55 | ),
56 | charger_idx=0,
57 | charger_cfg=epl.assets.evs.ChargerConfig(
58 | name="ca", power_min_mw=0, power_max_mw=0
59 | ),
60 | cfg=epl.assets.evs.EVsConfig(
61 | name="evs",
62 | charger_cfgs=np.array([0]),
63 | spill_charger_cfgs=np.array([0]),
64 | charge_event_cfgs=np.array([0]),
65 | freq_mins=0,
66 | charge_events=np.array([[0], [0]]),
67 | ),
68 | initial_soc_mwh=0.0,
69 | final_soc_mwh=0.0,
70 | electric_charge_mwh=0.0,
71 | electric_charge_binary=0,
72 | electric_discharge_mwh=0.0,
73 | electric_discharge_binary=0,
74 | electric_loss_mwh=0.0,
75 | ),
76 | epl.assets.evs.EVsConfig(
77 | name="evs",
78 | charger_cfgs=np.array([0]),
79 | spill_charger_cfgs=np.array([0]),
80 | charge_event_cfgs=np.array([0]),
81 | freq_mins=0,
82 | charge_events=np.array([[0], [0]]),
83 | ),
84 | epl.assets.evs.EVsArrayOneInterval(
85 | i=0,
86 | cfg=epl.assets.evs.EVsConfig(
87 | name="evs",
88 | charger_cfgs=np.array([0]),
89 | spill_charger_cfgs=np.array([0]),
90 | charge_event_cfgs=np.array([0]),
91 | freq_mins=0,
92 | charge_events=np.array([[0], [0]]),
93 | ),
94 | initial_soc_mwh=np.array([0]),
95 | final_soc_mwh=np.array([0]),
96 | electric_charge_mwh=np.array([0]),
97 | electric_charge_binary=np.array([0]),
98 | electric_discharge_mwh=np.array([0]),
99 | electric_discharge_binary=np.array([0]),
100 | electric_loss_mwh=np.array([0]),
101 | charge_event_idxs=np.array([0]),
102 | charger_idxs=np.array([0]),
103 | ),
104 | epl.Spill(),
105 | epl.Valve(),
106 | epl.Optimizer(),
107 | epl.accounting.accounting.Account(cost=0, emissions=0),
108 | epl.accounting.accounting.Accounts(
109 | electricity=epl.accounting.accounting.ElectricityAccount(
110 | import_cost=0,
111 | export_cost=0,
112 | cost=0,
113 | import_emissions=0,
114 | export_emissions=0,
115 | emissions=0,
116 | ),
117 | gas=epl.accounting.accounting.GasAccount(
118 | cost=0,
119 | emissions=0,
120 | ),
121 | custom=epl.accounting.accounting.CustomAccount(
122 | cost=0,
123 | emissions=0,
124 | ),
125 | profit=0,
126 | cost=0,
127 | emissions=0,
128 | ),
129 | epl.interval_data.IntervalVars(),
130 | epl.SimulationResult(
131 | status=epl.optimizer.OptimizationStatus(
132 | status="Optimal", feasible=True, objective=0.0
133 | ),
134 | site=epl.Site(assets=[], electricity_prices=[10]),
135 | assets=[],
136 | results=pd.DataFrame(),
137 | feasible=True,
138 | spill=False,
139 | ),
140 | ]
141 | for th in things:
142 | repr(th)
143 | str(th)
144 | print(th)
145 |
--------------------------------------------------------------------------------
/tests/test_spill_warnings.py:
--------------------------------------------------------------------------------
1 | """Tests for the spill warnings."""
2 |
3 | import pydantic
4 | import pytest
5 | from _pytest.capture import CaptureFixture
6 |
7 | import energypylinear as epl
8 | from energypylinear.flags import Flags
9 |
10 |
11 | def test_spill_validation() -> None:
12 | """Check we fail for incorrectly named spill asset."""
13 | with pytest.raises(pydantic.ValidationError):
14 | epl.assets.spill.SpillConfig(name="not-valid")
15 | epl.assets.spill.SpillConfig(name="valid-spill")
16 |
17 |
18 | def test_chp_spill(capsys: CaptureFixture) -> None:
19 | """Check spill with gas turbine."""
20 | asset = epl.CHP(
21 | electric_power_max_mw=100,
22 | electric_power_min_mw=50,
23 | electric_efficiency_pct=0.3,
24 | high_temperature_efficiency_pct=0.5,
25 | electricity_prices=[
26 | 1000,
27 | ],
28 | gas_prices=20.0,
29 | high_temperature_load_mwh=[
30 | 20,
31 | ],
32 | freq_mins=60,
33 | include_spill=True,
34 | )
35 | """
36 | - high electricity price, low heat demand
37 | - expect generator to run full load and dump heat to low temperature
38 | """
39 | simulation = asset.optimize()
40 | row = simulation.results.iloc[0, :]
41 | assert row[f"{asset.cfg.name}-electric_generation_mwh"] == 100
42 | capture = capsys.readouterr()
43 | assert "warn_spills" in capture.out
44 |
45 | # now check we fail when we want to
46 | with pytest.raises(ValueError):
47 | flags = Flags(fail_on_spill_asset_use=True)
48 | asset = epl.CHP(
49 | electric_power_max_mw=100,
50 | electric_power_min_mw=50,
51 | electric_efficiency_pct=0.3,
52 | high_temperature_efficiency_pct=0.5,
53 | electricity_prices=[
54 | 1000,
55 | ],
56 | gas_prices=20,
57 | high_temperature_load_mwh=[
58 | 20,
59 | ],
60 | freq_mins=60,
61 | include_spill=True,
62 | )
63 | asset.optimize(
64 | flags=flags,
65 | )
66 |
67 | """
68 | - low electricity price, low heat demand
69 | - expect all heat demand met from boiler
70 | """
71 | asset = epl.CHP(
72 | electric_power_max_mw=100,
73 | electric_power_min_mw=50,
74 | electric_efficiency_pct=0.3,
75 | high_temperature_efficiency_pct=0.5,
76 | electricity_prices=[-100],
77 | gas_prices=20,
78 | high_temperature_load_mwh=[20],
79 | freq_mins=60,
80 | include_spill=True,
81 | )
82 | asset.optimize()
83 | capture = capsys.readouterr()
84 | assert "warn_spills" not in capture.out
85 |
86 |
87 | def test_evs_spill() -> None:
88 | """Test EV spills."""
89 |
90 | asset = epl.EVs(
91 | chargers_power_mw=[100],
92 | charge_events_capacity_mwh=[50, 100, 30],
93 | charger_turndown=0.0,
94 | charge_event_efficiency=1.0,
95 | electricity_prices=[-100, 50],
96 | charge_events=[
97 | [1, 0],
98 | [1, 0],
99 | [1, 0],
100 | ],
101 | include_spill=True,
102 | )
103 | simulation = asset.optimize()
104 | assert simulation.results["total-spills_mwh"].sum() > 0
105 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | """Test utility functions."""
2 | import numpy as np
3 |
4 | from energypylinear.utils import repeat_to_match_length
5 |
6 |
7 | def test_repeat_to_match_length() -> None:
8 | """Tests the repeat_to_match_length function."""
9 | assert all(
10 | repeat_to_match_length(np.array([1.0, 2.0, 3.0]), np.zeros(5))
11 | == np.array([1.0, 2.0, 3.0, 1.0, 2.0])
12 | )
13 |
--------------------------------------------------------------------------------