├── .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 | [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](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 | ![](../static/battery.png) 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 | ![](../static/battery-fast.png) 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 | ![](../static/ev-validation-1.png) 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 | ![](../static/ev-validation-2.png) 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 | ![](../static/ev-validation-3.png) 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 | ![](../static/ev-validation-4.png) 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 | ![](../static/ev-validation-5.png) 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 | ![](../static/battery-performance.png) 8 | 9 | ## EVs 10 | 11 | ![](../static/evs-performance.png) 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 100% 19 | 100% 20 | 21 | 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 | --------------------------------------------------------------------------------