From 558be05bdc198a7de87ba6e6015ba7e8a5f7338e Mon Sep 17 00:00:00 2001 From: Adam Green Date: Sat, 14 Sep 2024 17:49:45 +1200 Subject: [PATCH] Custom Interval Data & Network Charges (#73) --- Makefile | 10 +- docs/docs/assets/battery.md | 20 +- docs/docs/assets/chp.md | 14 +- docs/docs/assets/evs.md | 8 +- docs/docs/assets/heat-pump.md | 13 +- docs/docs/assets/renewable-generator.md | 7 +- docs/docs/how-to/custom-interval-data.md | 88 +++++++++ docs/docs/how-to/custom-objectives.md | 2 +- docs/docs/how-to/dispatch-site.md | 6 +- docs/docs/how-to/network-charges.md | 157 ++++++++++++++++ docs/mkdocs.yml | 3 +- energypylinear/__init__.py | 8 +- energypylinear/assets/asset.py | 20 +- energypylinear/assets/battery.py | 47 +++-- energypylinear/assets/chp.py | 52 ++++-- energypylinear/assets/evs.py | 79 ++++---- energypylinear/assets/heat_pump.py | 64 ++++--- energypylinear/assets/renewable_generator.py | 113 +++++++++--- energypylinear/assets/site.py | 132 ++++++++++---- energypylinear/objectives.py | 17 +- energypylinear/results/checks.py | 2 +- energypylinear/results/extract.py | 22 +-- tests/assets/test_battery.py | 31 +++- tests/assets/test_chp.py | 4 + tests/assets/test_heat_pump.py | 5 + tests/assets/test_renewable_generator.py | 3 + tests/assets/test_site.py | 2 +- tests/common.py | 39 ++++ ...complex_terms.py => test_complex_terms.py} | 0 tests/test_custom_objectives.py | 39 +--- tests/test_extra_interval_data.py | 172 ++++++++++++++++++ tests/test_network_charges.py | 75 ++++++++ tests/test_plot.py | 2 + tests/test_spill_warnings.py | 4 + 34 files changed, 1001 insertions(+), 259 deletions(-) create mode 100644 docs/docs/how-to/custom-interval-data.md create mode 100644 docs/docs/how-to/network-charges.md create mode 100644 tests/common.py rename tests/{test_custom_objectives_complex_terms.py => test_complex_terms.py} (100%) create mode 100644 tests/test_extra_interval_data.py create mode 100644 tests/test_network_charges.py diff --git a/Makefile b/Makefile index 3fafcf5a..9ae90ab8 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ TEST_ARGS = export test: setup-test test-docs - pytest tests --cov=energypylinear --cov-report=html -n $(PARALLEL) --color=yes --durations=5 --verbose --ignore tests/phmdoctest $(TEST_ARGS) + pytest tests --cov=energypylinear --cov-report=html --cov-report=term-missing -n $(PARALLEL) --color=yes --durations=5 --verbose --ignore tests/phmdoctest $(TEST_ARGS) # -coverage combine # -coverage html -coverage report @@ -56,7 +56,7 @@ generate-test-docs: setup-test bash ./tests/generate-test-docs.sh test-docs: setup-test generate-test-docs - pytest tests/phmdoctest -n $(PARALLEL) --dist loadfile --color=yes --verbose $(TEST_ARGS) + pytest tests/phmdoctest -n 1 --dist loadfile --color=yes --verbose $(TEST_ARGS) # ----- CHECK ----- @@ -71,7 +71,7 @@ static: setup-static rm -rf ./tests/phmdoctest mypy --version mypy $(MYPY_ARGS) ./energypylinear - mypy $(MYPY_ARGS) ./tests + mypy $(MYPY_ARGS) ./tests --explicit-package-bases lint: setup-check rm -rf ./tests/phmdoctest @@ -81,13 +81,13 @@ lint: setup-check ruff format --check **/*.py poetry check -CHECK_DOCSTRINGS=./energypylinear/assets/battery.py ./energypylinear/objectives.py +CHECK_DOCSTRINGS=./energypylinear/objectives.py ./energypylinear/assets/battery.py ./energypylinear/assets/renewable_generator.py # currently only run manually lint-docstrings: flake8 --extend-ignore E501 --exclude=__init__.py,poc --exit-zero $(CHECK_DOCSTRINGS) pydocstyle $(CHECK_DOCSTRINGS) - pylint $(CHECK_DOCSTRINGS) + # pylint $(CHECK_DOCSTRINGS) # ----- FORMATTING ----- diff --git a/docs/docs/assets/battery.md b/docs/docs/assets/battery.md index d55b2935..9ec31dff 100644 --- a/docs/docs/assets/battery.md +++ b/docs/docs/assets/battery.md @@ -26,7 +26,8 @@ asset = epl.Battery( freq_mins=60, initial_charge_mwh=1, final_charge_mwh=3, - name="battery" + name="battery", + include_spill=True ) simulation = asset.optimize() @@ -36,12 +37,13 @@ assert all( "site-import_power_mwh", "site-export_power_mwh", "site-electricity_prices", + "site-export_electricity_prices", "site-electricity_carbon_intensities", + "site-gas_prices", + "site-electric_load_mwh", "site-high_temperature_load_mwh", "site-low_temperature_load_mwh", "site-low_temperature_generation_mwh", - "site-gas_prices", - "site-electric_load_mwh", "spill-electric_generation_mwh", "spill-electric_load_mwh", "spill-high_temperature_generation_mwh", @@ -68,7 +70,7 @@ assert all( "total-spills_mwh", "total-electric_loss_mwh", "site-electricity_balance_mwh", - ] + ], ) ``` @@ -153,11 +155,11 @@ print(balance) ``` input accumulation output balance import generation export load charge discharge loss spills soc -0 0.444444 -0.444444 0.0 True 0.444444 0.0 0.0 0.0 0.444444 0.0 0.044444 0.0 0.0 -1 2.000000 -2.000000 0.0 True 2.000000 0.0 0.0 0.0 2.000000 0.0 0.200000 0.0 0.0 -2 0.000000 2.000000 2.0 True 0.000000 0.0 2.0 0.0 0.000000 2.0 0.000000 0.0 0.0 -3 2.000000 -2.000000 0.0 True 2.000000 0.0 0.0 0.0 2.000000 0.0 0.200000 0.0 0.0 -4 0.000000 2.000000 2.0 True 0.000000 0.0 2.0 0.0 0.000000 2.0 0.000000 0.0 0.0 +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 +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 +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 +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 +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 ``` 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. diff --git a/docs/docs/assets/chp.md b/docs/docs/assets/chp.md index e8b670ab..c4c939b9 100644 --- a/docs/docs/assets/chp.md +++ b/docs/docs/assets/chp.md @@ -36,7 +36,7 @@ asset = epl.CHP( low_temperature_efficiency_pct=0.2, electricity_prices=[100, 50, 200, -100, 0, 200, 100, -100], high_temperature_load_mwh=[100, 50, 200, 40, 0, 200, 100, 100], - low_temperature_load_mwh=20, + low_temperature_load_mwh=20 ) simulation = asset.optimize() @@ -47,19 +47,13 @@ assert all( "site-import_power_mwh", "site-export_power_mwh", "site-electricity_prices", + "site-export_electricity_prices", "site-electricity_carbon_intensities", + "site-gas_prices", + "site-electric_load_mwh", "site-high_temperature_load_mwh", "site-low_temperature_load_mwh", "site-low_temperature_generation_mwh", - "site-gas_prices", - "site-electric_load_mwh", - "spill-electric_generation_mwh", - "spill-electric_load_mwh", - "spill-high_temperature_generation_mwh", - "spill-low_temperature_generation_mwh", - "spill-high_temperature_load_mwh", - "spill-low_temperature_load_mwh", - "spill-gas_consumption_mwh", "chp-electric_generation_mwh", "chp-gas_consumption_mwh", "chp-high_temperature_generation_mwh", diff --git a/docs/docs/assets/evs.md b/docs/docs/assets/evs.md index d4ebdd71..eae5053e 100644 --- a/docs/docs/assets/evs.md +++ b/docs/docs/assets/evs.md @@ -30,6 +30,7 @@ asset = epl.EVs( charger_turndown=0.1, electricity_prices=electricity_prices, charge_events=charge_events, + include_spill=True ) simulation = asset.optimize() @@ -40,12 +41,13 @@ assert all( "site-import_power_mwh", "site-export_power_mwh", "site-electricity_prices", + "site-export_electricity_prices", "site-electricity_carbon_intensities", + "site-gas_prices", + "site-electric_load_mwh", "site-high_temperature_load_mwh", "site-low_temperature_load_mwh", "site-low_temperature_generation_mwh", - "site-gas_prices", - "site-electric_load_mwh", "spill-electric_generation_mwh", "spill-electric_load_mwh", "spill-high_temperature_generation_mwh", @@ -100,7 +102,7 @@ assert all( "total-spills_mwh", "total-electric_loss_mwh", "site-electricity_balance_mwh", - ] + ], ) ``` diff --git a/docs/docs/assets/heat-pump.md b/docs/docs/assets/heat-pump.md index 0a555742..7279e3ff 100644 --- a/docs/docs/assets/heat-pump.md +++ b/docs/docs/assets/heat-pump.md @@ -24,6 +24,7 @@ asset = epl.HeatPump( electricity_prices=[100, -100], high_temperature_load_mwh=3.0, low_temperature_generation_mwh=3.0, + include_spill=True ) simulation = asset.optimize(verbose=False) print( @@ -43,12 +44,13 @@ assert all( "site-import_power_mwh", "site-export_power_mwh", "site-electricity_prices", + "site-export_electricity_prices", "site-electricity_carbon_intensities", + "site-gas_prices", + "site-electric_load_mwh", "site-high_temperature_load_mwh", "site-low_temperature_load_mwh", "site-low_temperature_generation_mwh", - "site-gas_prices", - "site-electric_load_mwh", "spill-electric_generation_mwh", "spill-electric_load_mwh", "spill-high_temperature_generation_mwh", @@ -75,7 +77,7 @@ assert all( "total-spills_mwh", "total-electric_loss_mwh", "site-electricity_balance_mwh", - ] + ], ) ``` @@ -126,6 +128,7 @@ asset = epl.HeatPump( electricity_prices=[100, -100], high_temperature_load_mwh=3.0, low_temperature_generation_mwh=4.0, + include_spill=True ) simulation = asset.optimize(verbose=4) print(simulation.results[ @@ -171,6 +174,7 @@ asset = epl.HeatPump( electricity_prices=[-100, -100, -100], high_temperature_load_mwh=[3.0, 0.5, 3.0], low_temperature_generation_mwh=[4.0, 4.0, 0.5], + include_spill=True, include_valve=False ) simulation = asset.optimize( @@ -219,7 +223,8 @@ asset = epl.HeatPump( electricity_prices=[-100, -100, -100], high_temperature_load_mwh=[3.0, 0.5, 3.0], low_temperature_generation_mwh=[4.0, 4.0, 0.0], - include_valve=True + include_valve=True, + include_spill=True, ) simulation = asset.optimize( verbose=4, diff --git a/docs/docs/assets/renewable-generator.md b/docs/docs/assets/renewable-generator.md index 703f22a6..2659e8e1 100644 --- a/docs/docs/assets/renewable-generator.md +++ b/docs/docs/assets/renewable-generator.md @@ -19,12 +19,13 @@ assert all( "site-import_power_mwh", "site-export_power_mwh", "site-electricity_prices", + "site-export_electricity_prices", "site-electricity_carbon_intensities", + "site-gas_prices", + "site-electric_load_mwh", "site-high_temperature_load_mwh", "site-low_temperature_load_mwh", "site-low_temperature_generation_mwh", - "site-gas_prices", - "site-electric_load_mwh", "wind-electric_generation_mwh", "total-electric_generation_mwh", "total-electric_load_mwh", @@ -38,7 +39,7 @@ assert all( "total-spills_mwh", "total-electric_loss_mwh", "site-electricity_balance_mwh", - ] + ], ) ``` diff --git a/docs/docs/how-to/custom-interval-data.md b/docs/docs/how-to/custom-interval-data.md new file mode 100644 index 00000000..e7134b95 --- /dev/null +++ b/docs/docs/how-to/custom-interval-data.md @@ -0,0 +1,88 @@ +Interval data is a key input to an `energypylinear` simulation. + +By default, `energypylinear` accepts interval data for things like electricity prices, carbon intensities and site electricity and heat consumption: + + +```python +--8<-- "energypylinear/assets/site.py:site" +``` + +These arguments are passed to the `SiteIntervalData` object, which is responsible for managing interval data for a site: + +```python +import energypylinear as epl + +asset = epl.Battery(electricity_prices=[100, 50, 200]) +print(asset.site.cfg.interval_data) +``` + +``` +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]) +``` + +## Custom Interval Data + +Often you will want to use different interval data for your simulation - for example modelling site network charges. + +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. + +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: + +```python +import energypylinear as epl + +electricity_prices = [100, 50, 200] +asset = epl.Battery(electricity_prices=[100, 50, 200], network_charges=[10, 20, 30]) +print(asset.site.cfg.interval_data) +``` + +``` +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.]) +``` + +## Custom Interval Data in Simulation Results + +All custom interval data will appear in the simulation results: + +```python +import energypylinear as epl + +asset = epl.Battery(electricity_prices=[100, 50, 200], network_charges=[10, 20, 30]) +simulation = asset.optimize(verbose=3) +print(simulation.results["site-network_charges"]) +``` + +``` +0 10.0 +1 20.0 +2 30.0 +Name: site-network_charges, dtype: float64 +``` + +## Custom Interval Data in Custom Objective Functions + +Custom interval data can be used in a custom objective function: + +```python +import energypylinear as epl + +asset = epl.Battery(electricity_prices=[100, 50, 200], network_charges=[10, 20, 30]) +simulation = asset.optimize( + objective={ + "terms": [ + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "electricity_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "electricity_prices", + "coefficient": -1, + }, + ] + }, + verbose=3 +) +``` diff --git a/docs/docs/how-to/custom-objectives.md b/docs/docs/how-to/custom-objectives.md index 6e80ab7c..eee371af 100644 --- a/docs/docs/how-to/custom-objectives.md +++ b/docs/docs/how-to/custom-objectives.md @@ -110,7 +110,7 @@ print(simulate(carbon_price=50, seed=42, n=72)) ``` ``` - + ``` We can validate that our custom objective function is working as expected by running simulations across many carbon prices: diff --git a/docs/docs/how-to/dispatch-site.md b/docs/docs/how-to/dispatch-site.md index bcebd26c..5dc26292 100644 --- a/docs/docs/how-to/dispatch-site.md +++ b/docs/docs/how-to/dispatch-site.md @@ -1,8 +1,10 @@ # Multiple Assets with the Site API -The `epl.Site` allows optimizing many assets at the same time. The site is a list of `energypylinear` asset models, like `epl.Battery` or `epl.RenewableGenerator`. +`energypylinear` can optimize many assets in a single linear program. -Below we give some examples of typical configurations of energy assets using an `epl.Site`. +The `epl.Site` accepts a list of `energypylinear` asset models, like `epl.Battery` or `epl.RenewableGenerator`. + +Below are examples of typical configurations of multiple energy assets using a `epl.Site`. ## Fast & Slow Battery diff --git a/docs/docs/how-to/network-charges.md b/docs/docs/how-to/network-charges.md new file mode 100644 index 00000000..21b8e318 --- /dev/null +++ b/docs/docs/how-to/network-charges.md @@ -0,0 +1,157 @@ +`energypylinear` has the ability to optimize for a network charge. + +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. + +## No Network Charge + +### Asset, Interval Data and Objective Function + +First we will setup a battery with no network charge, and optimize it for electricity prices: + + +```python +import energypylinear as epl + +electricity_prices = [50, 100, 150] +asset = epl.Battery(electricity_prices=electricity_prices, efficiency=0.9) +bau = asset.optimize( + { + "terms": [ + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "electricity_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "electricity_prices", + "coefficient": -1, + }, + ] + }, + verbose=5 +) +``` + +### Results + +We can then calculate the net import and battery charge: + + +```python +results = bau.results +results["battery-net_charge_mwh"] = ( + results["battery-electric_charge_mwh"] - results["battery-electric_discharge_mwh"] +) +results["site-net_import_mwh"] = ( + results["site-import_power_mwh"] - results["site-export_power_mwh"] +) +print( + bau.results[ + [ + "site-electricity_prices", + "site-net_import_mwh", + "battery-net_charge_mwh", + "battery-electric_final_charge_mwh", + ] + ] +) +``` + +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: + +``` + site-electricity_prices site-net_import_mwh battery-net_charge_mwh battery-electric_final_charge_mwh +0 50.0 2.000000 2.000000 1.8 +1 100.0 0.222222 0.222222 2.0 +2 150.0 -2.000000 -2.000000 0.0 +``` + +## With Network Charge + +### Asset and Interval Data + +By default, `energypylinear` uses interval data like `electricity_prices` or `electricity_carbon_intensities`. This interval data is supplied when initializing an asset or site. + +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. + +Below we setup a battery with both electricity prices and a network charge: + + +```python +import energypylinear as epl + +assert electricity_prices == [50, 100, 150] +network_charge = [0, 100, 0] +asset = epl.Battery(electricity_prices=electricity_prices, network_charge=network_charge) +``` + +### Objective Function + +By default, `energypylinear` has two built-in objective functions - `price` and `carbon`. + +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. + +Below we optimize our battery with a custom objective function: + + +```python +network_charge = asset.optimize( + { + "terms": [ + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "electricity_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "electricity_prices", + "coefficient": -1, + }, + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "network_charge", + "coefficient": 1, + }, + ] + }, + verbose=3 +) +``` + +### Results + + +```python +results = network_charge.results +results["battery-net_charge_mwh"] = ( + results["battery-electric_charge_mwh"] - results["battery-electric_discharge_mwh"] +) +results["site-net_import_mwh"] = ( + results["site-import_power_mwh"] - results["site-export_power_mwh"] +) +print( + network_charge.results[ + [ + "site-electricity_prices", + "site-network_charge", + "site-net_import_mwh", + "battery-net_charge_mwh", + "battery-electric_final_charge_mwh", + ] + ] +) +``` + +We now see that our battery has not charged during the second interval, where we have a high site network charge: + +``` + site-electricity_prices site-network_charge site-net_import_mwh battery-net_charge_mwh battery-electric_final_charge_mwh +0 50.0 0.0 2.0 2.0 1.8 +1 100.0 100.0 0.0 0.0 1.8 +2 150.0 0.0 -1.8 -1.8 0.0 +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index f9c54279..5aad1e89 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -65,10 +65,11 @@ nav: - Multiple Assets: how-to/dispatch-site.md - Carbon: how-to/price-carbon.md - Forecast: how-to/dispatch-forecast.md - - Battery Cycles: how-to/battery-cycles.md + - Network Charges: how-to/network-charges.md - Customization: - Constraints: how-to/custom-constraints.md - Objective Functions: how-to/custom-objectives.md + - Interval Data: how-to/custom-interval-data.md - Changelog: changelog.md # - Performance: performance.md # - Measure Forecast Quality: index.md diff --git a/energypylinear/__init__.py b/energypylinear/__init__.py index 0558d9d8..2a372fca 100644 --- a/energypylinear/__init__.py +++ b/energypylinear/__init__.py @@ -1,10 +1,13 @@ """A library for mixed-integer linear optimization of energy assets.""" +# isort: skip_file from pulp import LpVariable +from energypylinear.flags import Flags +from energypylinear.optimizer import Optimizer, OptimizerConfig from energypylinear import assets, data_generation, plot from energypylinear.accounting import get_accounts -from energypylinear.assets.asset import Asset +from energypylinear.assets.asset import Asset, OptimizableAsset from energypylinear.assets.battery import Battery from energypylinear.assets.boiler import Boiler from energypylinear.assets.chp import CHP @@ -15,11 +18,9 @@ from energypylinear.assets.spill import Spill from energypylinear.assets.valve import Valve from energypylinear.constraints import Constraint, ConstraintTerm -from energypylinear.flags import Flags from energypylinear.freq import Freq from energypylinear.interval_data import IntervalVars from energypylinear.objectives import CustomObjectiveFunction, Term, get_objective -from energypylinear.optimizer import Optimizer, OptimizerConfig from energypylinear.results.checks import check_results from energypylinear.results.extract import SimulationResult, extract_results @@ -30,6 +31,7 @@ "Constraint", "ConstraintTerm", "CustomObjectiveFunction", + "OptimizableAsset", "Term", "get_objective", "RenewableGenerator", diff --git a/energypylinear/assets/asset.py b/energypylinear/assets/asset.py index c3168884..c7d97d5a 100644 --- a/energypylinear/assets/asset.py +++ b/energypylinear/assets/asset.py @@ -11,6 +11,8 @@ class Asset(abc.ABC): """Abstract Base Class for an Asset.""" + site: "epl.Site" + @abc.abstractmethod def __init__(self) -> None: """Initializes the asset.""" @@ -48,11 +50,19 @@ def constrain_after_intervals( pass -# TODO - maybe have a separate OptimizeableAsset -# @abc.abstractmethod -# def optimize(self) -> typing.Any | None: -# """Optimize the asset.""" -# pass +class OptimizableAsset(Asset): + """Abstract Base Class of an Optimizable Asset.""" + + @abc.abstractmethod + def optimize( + self, + objective: "str | dict | epl.CustomObjectiveFunction" = "price", + verbose: int | bool = 2, + flags: "epl.Flags" = epl.Flags(), + optimizer_config: "epl.OptimizerConfig | dict" = epl.OptimizerConfig(), + ) -> "epl.SimulationResult": + """Optimize sites dispatch using a mixed-integer linear program.""" + pass class AssetOneInterval(pydantic.BaseModel): diff --git a/energypylinear/assets/battery.py b/energypylinear/assets/battery.py index 029c54dd..d18e1639 100644 --- a/energypylinear/assets/battery.py +++ b/energypylinear/assets/battery.py @@ -1,6 +1,7 @@ """Battery asset for optimizing battery dispatch for price or carbon arbitrage.""" import pathlib +import typing import numpy as np import pulp @@ -171,8 +172,8 @@ def constrain_initial_final_charge( optimizer.constrain(final.electric_final_charge_mwh == final.cfg.final_charge_mwh) -class Battery(epl.Asset): - """Electric battery asset, able to charge and discharge electricity.""" +class Battery(epl.OptimizableAsset): + """The battery asset can charge, store and discharge electricity.""" def __init__( self, @@ -180,30 +181,37 @@ def __init__( discharge_power_mw: float | None = None, capacity_mwh: float = 4.0, efficiency_pct: float = 0.9, - name: str = "battery", + initial_charge_mwh: float = 0.0, + final_charge_mwh: float | None = None, electricity_prices: np.ndarray | list[float] | float | None = None, export_electricity_prices: np.ndarray | list[float] | float | None = None, electricity_carbon_intensities: np.ndarray | list[float] | float | None = None, - initial_charge_mwh: float = 0.0, - final_charge_mwh: float | None = None, + name: str = "battery", freq_mins: int = defaults.freq_mins, constraints: "list[epl.Constraint] | list[dict] | None" = None, + include_spill: bool = False, + **kwargs: typing.Any, ): - """Initialize the asset. + """ + Initialize a Battery asset. Args: - power_mw: Maximum charge rate in megawatts. Will define both the charge and discharge rate if `discharge_power_mw` is None. + power_mw: Maximum charge rate in megawatts. + Will define both the charge and discharge rate if `discharge_power_mw` is None. discharge_power_mw: Maximum discharge rate in megawatts. capacity_mwh: Battery capacity in megawatt hours. efficiency_pct: Round-trip efficiency of the battery. - name: The asset name. - electricity_prices: The price of import electricity in each interval. Will define both import and export prices if `export_electricity_prices` is None. - export_electricity_prices: The price of export electricity in each interval. - electricity_carbon_intensities: Carbon intensity of electricity in each interval. initial_charge_mwh: Initial charge state of the battery in megawatt hours. final_charge_mwh: Final charge state of the battery in megawatt hours. + electricity_prices: The price of import electricity in each interval. + Will define both import and export prices if `export_electricity_prices` is None. + export_electricity_prices: The price of export electricity in each interval. + electricity_carbon_intensities: Carbon intensity of electricity in each interval. + name: The asset name. freq_mins: length of the simulation intervals in minutes. constraints: Additional custom constraints to apply to the linear program. + include_spill: Whether to include a spill asset in the site. + kwargs: Extra keyword arguments attempted to be used as custom interval data. """ initial_charge_mwh, final_charge_mwh = setup_initial_final_charge( initial_charge_mwh, final_charge_mwh, capacity_mwh @@ -225,7 +233,9 @@ def __init__( ) if electricity_prices is not None or electricity_carbon_intensities is not None: - assets = [self, epl.Spill()] + assets: list[epl.Asset] = [self] + if include_spill: + assets.append(epl.Spill()) self.site = epl.Site( assets=assets, electricity_prices=electricity_prices, @@ -233,12 +243,14 @@ def __init__( electricity_carbon_intensities=electricity_carbon_intensities, freq_mins=self.cfg.freq_mins, constraints=constraints, + **kwargs, ) # TODO - could warn that if constraints are specified, but not prices, they will be ignored def __repr__(self) -> str: - """Return a string representation of self. + """ + Create a string representation of self. Returns: A string representation of self. @@ -248,7 +260,8 @@ def __repr__(self) -> str: def one_interval( self, optimizer: Optimizer, i: int, freq: Freq, flags: Flags = Flags() ) -> BatteryOneInterval: - """Generate linear program data for one interval. + """ + Generate linear program data for one interval. Args: optimizer: Linear program optimizer. @@ -299,7 +312,8 @@ def constrain_within_interval( freq: Freq, flags: Flags = Flags(), ) -> None: - """Constrain asset within an interval. + """ + Constrain asset within an interval. Args: optimizer: Linear program optimizer. @@ -326,7 +340,8 @@ def constrain_after_intervals( optimizer: Optimizer, ivars: "epl.IntervalVars", ) -> None: - """Constrain asset after all intervals. + """ + Constrain asset after all intervals. Args: optimizer: Linear program optimizer. diff --git a/energypylinear/assets/chp.py b/energypylinear/assets/chp.py index e1ce843d..d28102c9 100644 --- a/energypylinear/assets/chp.py +++ b/energypylinear/assets/chp.py @@ -47,8 +47,9 @@ class CHPOneInterval(AssetOneInterval): low_temperature_generation_mwh: pulp.LpVariable -class CHP(epl.Asset): - """CHP asset - handles optimization and plotting of results over many intervals. +class CHP(epl.OptimizableAsset): + """ + CHP asset - handles optimization and plotting of results over many intervals. A CHP (combined heat and power) generator generates electricity, high temperature and low temperature heat from natural gas. @@ -56,19 +57,6 @@ class CHP(epl.Asset): This asset can be used to model gas turbines, gas engines or open cycle generators like diesel generators. - Args: - electric_power_max_mw - maximum electric power output of the generator in mega-watts. - electric_power_min_mw - minimum electric power output of the generator in mega-watts. - electric_efficiency_pct - electric efficiency of the generator, measured in percentage. - high_temperature_efficiency_pct - high temperature efficiency of the generator, measured in percentage. - low_temperature_efficiency_pct - the low temperature efficiency of the generator, measured in percentage. - electricity_prices: the price of electricity in each interval. - gas_prices: the prices of natural gas, used in CHP and boilers in each interval. - electricity_carbon_intensities: carbon intensity of electricity in each interval. - high_temperature_load_mwh: high temperature load of the site in mega-watt hours. - low_temperature_load_mwh: low temperature load of the site in mega-watt hours. - freq_mins: the size of an interval in minutes. - Make sure to get your efficiencies and gas prices on the same basis (HHV or LHV). """ @@ -79,8 +67,6 @@ def __init__( electric_power_min_mw: float = 0.0, high_temperature_efficiency_pct: float = 0.0, low_temperature_efficiency_pct: float = 0.0, - name: str = "chp", - freq_mins: int = defaults.freq_mins, electricity_prices: np.ndarray | list[float] | float | None = None, export_electricity_prices: np.ndarray | list[float] | float | None = None, electricity_carbon_intensities: np.ndarray | list[float] | float | None = None, @@ -88,9 +74,34 @@ def __init__( high_temperature_load_mwh: np.ndarray | list[float] | float | None = None, low_temperature_load_mwh: np.ndarray | list[float] | float | None = None, low_temperature_generation_mwh: np.ndarray | list[float] | float | None = None, + name: str = "chp", + freq_mins: int = defaults.freq_mins, constraints: "list[epl.Constraint] | list[dict] | None" = None, + include_spill: bool = False, + **kwargs: typing.Any, ): - """Initializes the asset.""" + """ + Initialize a Combined Heat and Power (CHP) asset. + + Args: + electric_power_max_mw: Maximum electric power output of the generator in mega-watts. + electric_power_min_mw: Minimum electric power output of the generator in mega-watts. + electric_efficiency_pct: Electric efficiency of the generator, measured in percentage. + high_temperature_efficiency_pct: High temperature efficiency of the generator, measured in percentage. + low_temperature_efficiency_pct: The low temperature efficiency of the generator, measured in percentage. + electricity_prices: Price of electricity in each interval. + export_electricity_prices: The price of export electricity in each interval. + electricity_carbon_intensities: carbon intensity of electricity in each interval. + gas_prices: Price of natural gas, used in CHP and boilers in each interval. + high_temperature_load_mwh: High temperature load of the site. + low_temperature_load_mwh: Low temperature load of the site. + low_temperature_generation_mwh: Avaialable low temperature generation of the site. + name: The asset name. + freq_mins: length of the simulation intervals in minutes. + constraints: Additional custom constraints to apply to the linear program. + include_spill: Whether to include a spill asset in the site. + kwargs: Extra keyword arguments attempted to be used as custom interval data. + """ self.cfg = CHPConfig( name=name, electric_power_min_mw=electric_power_min_mw, @@ -102,7 +113,9 @@ def __init__( ) if electricity_prices is not None or electricity_carbon_intensities is not None: - assets = [self, epl.Spill(), epl.Valve(), epl.Boiler()] + assets: list[epl.Asset] = [self, epl.Valve(), epl.Boiler()] + if include_spill: + assets.append(epl.Spill()) self.site = epl.Site( assets=assets, electricity_prices=electricity_prices, @@ -114,6 +127,7 @@ def __init__( low_temperature_generation_mwh=low_temperature_generation_mwh, freq_mins=self.cfg.freq_mins, constraints=constraints, + **kwargs, ) def __repr__(self) -> str: diff --git a/energypylinear/assets/evs.py b/energypylinear/assets/evs.py index 31f1c987..b71591fb 100644 --- a/energypylinear/assets/evs.py +++ b/energypylinear/assets/evs.py @@ -563,35 +563,11 @@ def constrain_initial_final_charge( # intentionally don't constrain the spill charger -class EVs: - """Electric vehicle asset, used to represent multiple chargers. - - Can handle vehicle-to-grid charging. - - Handles optimization and plotting of results over many intervals. - - Args: - chargers_power_mw: size of EV chargers in mega-watts. - charge_events_capacity_mwh: - 1D array of final SOC for each charge event. - Length is the number of charge events. - charge_event_efficiency: - Roundtrip efficiency of the charge event charge & discharge. - charger_turndown: - minimum charger output as a percent of the - charger size in mega-watts. - name: asset name - electricity_prices - the price of electricity in each interval. - electricity_carbon_intensities - carbon intensity of electricity in each interval. - charge_events: 2D matrix representing when a charge event is active. - Shape is (n_charge_events, n_timesteps). - A charge events matrix for 4 charge events over 5 intervals: - charge_events = [ - [1, 0, 0, 0, 0], - [0, 1, 1, 1, 0], - [0, 0, 0, 1, 1], - [0, 1, 0, 0, 0], - ] +class EVs(epl.OptimizableAsset): + """ + The EVs asset can charge, store and discharge electricity in mutliple charge events. + + Can handle both grid-to-vehicle and vehicle-to-grid charging. """ def __init__( @@ -601,17 +577,50 @@ def __init__( charge_events_capacity_mwh: np.ndarray | list[float], charge_event_efficiency: float = 0.9, charger_turndown: float = 0.1, - name: str = "evs", electricity_prices: np.ndarray | list[float] | np.ndarray | None = None, export_electricity_prices: np.ndarray | list[float] | np.ndarray | None = None, electricity_carbon_intensities: np.ndarray | list[float] | np.ndarray | None = None, + name: str = "evs", freq_mins: int = defaults.freq_mins, constraints: "list[epl.Constraint] | list[dict] | None" = None, + include_spill: bool = False, + **kwargs: typing.Any, ): - """Initialize an electric vehicle asset model.""" + """ + Initialize an Electric Vehicle asset. + + Args: + chargers_power_mw: size of EV chargers in mega-watts. + charge_events_capacity_mwh: + 1D array of final SOC for each charge event. + Length is the number of charge events. + charge_event_efficiency: + Roundtrip efficiency of the charge event charge & discharge. + charger_turndown: + minimum charger output as a percent of the + charger size in mega-watts. + electricity_prices: The price of import electricity in each interval. + Will define both import and export prices if `export_electricity_prices` is None. + export_electricity_prices: The price of export electricity in each interval. + electricity_carbon_intensities: Carbon intensity of electricity in each interval. + charge_events: 2D matrix representing when a charge event is active. + Shape is (n_charge_events, n_timesteps). + A charge events matrix for 4 charge events over 5 intervals: + charge_events = [ + [1, 0, 0, 0, 0], + [0, 1, 1, 1, 0], + [0, 0, 0, 1, 1], + [0, 1, 0, 0, 0], + ] + name: The asset name + freq_mins: length of the simulation intervals in minutes. + constraints: Additional custom constraints to apply to the linear program. + include_spill: Whether to include a spill asset in the site. + kwargs: Keyword arguments attempted to be used as extra interval data. + """ charger_cfgs = np.array( [ @@ -654,7 +663,9 @@ def __init__( ) if electricity_prices is not None or electricity_carbon_intensities is not None: - assets = [self, epl.Spill()] + assets: list[epl.Asset] = [self] + if include_spill: + assets.append(epl.Spill()) self.site = epl.Site( assets=assets, electricity_prices=electricity_prices, @@ -662,10 +673,12 @@ def __init__( electricity_carbon_intensities=electricity_carbon_intensities, freq_mins=self.cfg.freq_mins, constraints=constraints, + **kwargs, ) assert isinstance(self.site.cfg.interval_data.idx, np.ndarray) validate_ev_interval_data( - self.site.cfg.interval_data.idx, self.cfg.charge_events + self.site.cfg.interval_data.idx, + self.cfg.charge_events, ) def __repr__(self) -> str: diff --git a/energypylinear/assets/heat_pump.py b/energypylinear/assets/heat_pump.py index 189204f3..8c123a11 100644 --- a/energypylinear/assets/heat_pump.py +++ b/energypylinear/assets/heat_pump.py @@ -1,5 +1,6 @@ """Heat Pump asset.""" import pathlib +import typing import numpy as np import pulp @@ -46,35 +47,13 @@ class HeatPumpOneInterval(AssetOneInterval): high_temperature_generation_mwh: pulp.LpVariable -class HeatPump(epl.Asset): - """Heat pump asset - handles optimization and plotting of results over many intervals. - - A heat pump generates high temperature heat from low temperature heat and electricity. - - Args: - electric_power_mw: the maximum power input of the heat pump. - Measured in in mega-watts. - cop: the coefficient of performance of the heat pump. - The ratio of high temperature heat output over input electricity. - name: the asset name. - freq_mins: the size of an interval in minutes. - - include_valve: whether to allow heat to flow from high to low temperature. - electricity_prices: the price of electricity in each interval. - gas_prices: the prices of natural gas, used in CHP and boilers in each interval. - electricity_carbon_intensities: carbon intensity of electricity in each interval. - high_temperature_load_mwh: high temperature load of the site in mega-watt hours. - low_temperature_load_mwh: low temperature load of the site in mega-watt hours. - low_temperature_generation_mwh: low temperature heat generated by the site in mega-watt hours. - """ +class HeatPump(epl.OptimizableAsset): + """A heat pump generates high temperature heat from low temperature heat and electricity.""" def __init__( self, cop: float = 3.0, electric_power_mw: float = 1.0, - freq_mins: int = defaults.freq_mins, - include_valve: bool = True, - name: str = "heat-pump", electricity_prices: np.ndarray | list[float] | float | None = None, export_electricity_prices: np.ndarray | list[float] | float | None = None, electricity_carbon_intensities: np.ndarray | list[float] | float | None = None, @@ -82,9 +61,34 @@ def __init__( high_temperature_load_mwh: np.ndarray | list[float] | float | None = None, low_temperature_load_mwh: np.ndarray | list[float] | float | None = None, low_temperature_generation_mwh: np.ndarray | list[float] | float | None = None, + name: str = "heat-pump", + freq_mins: int = defaults.freq_mins, constraints: "list[epl.Constraint] | list[dict] | None" = None, + include_spill: bool = False, + include_valve: bool = True, + **kwargs: typing.Any, ): - """Initializes the asset.""" + """Initializes the asset. + + Args: + electric_power_mw: the maximum power input of the heat pump. + cop: the coefficient of performance of the heat pump. + The ratio of high temperature heat output over input electricity. + name: the asset name. + freq_mins: Size of an interval in minutes. + electricity_prices: Price of electricity in each interval. + gas_prices: Price of natural gas, used in CHP and boilers. + electricity_carbon_intensities: Carbon intensity of electricity. + high_temperature_load_mwh: High temperature load of the site. + low_temperature_load_mwh: Low temperature load of the site. + low_temperature_generation_mwh: low temperature heat generated by the site. + name: The asset name. + freq_mins: length of the simulation intervals in minutes. + constraints: Additional custom constraints to apply to the linear program. + include_valve: Whether to allow heat to flow from high to low temperature. + include_spill: Whether to include a spill asset in the site. + kwargs: Extra keyword arguments attempted to be used as custom interval data. + """ self.cfg = HeatPumpConfig( cop=cop, electric_power_mw=electric_power_mw, @@ -94,11 +98,10 @@ def __init__( ) if electricity_prices is not None or electricity_carbon_intensities is not None: - assets = [ - self, - epl.Spill(), - epl.Boiler(), - ] + assets: list[epl.Asset] = [self, epl.Boiler()] + + if include_spill: + assets.append(epl.Spill()) if include_valve: assets.append(epl.Valve()) @@ -113,6 +116,7 @@ def __init__( low_temperature_generation_mwh=low_temperature_generation_mwh, freq_mins=self.cfg.freq_mins, constraints=constraints, + **kwargs, ) def __repr__(self) -> str: diff --git a/energypylinear/assets/renewable_generator.py b/energypylinear/assets/renewable_generator.py index b2ed9e1c..25614bb6 100644 --- a/energypylinear/assets/renewable_generator.py +++ b/energypylinear/assets/renewable_generator.py @@ -1,7 +1,10 @@ """ Renewable Generator asset. -Suitable for modelling either turndownable wind or solar.""" +Suitable for modelling either turndownable wind or solar. +""" + +import typing import numpy as np import pydantic @@ -22,8 +25,14 @@ class RenewableGeneratorIntervalData(pydantic.BaseModel): @pydantic.field_validator("electric_generation_mwh", mode="after") @classmethod def handle_single_float(cls, value: float | list | np.ndarray) -> list | np.ndarray: - """Handles case where we want a single value broadcast to the length - of the interval data. + """ + Handle case where we want a single value broadcast to the length of the interval data. + + Args: + value: The value to broadcast. + + Returns: + The value as a list or np.ndarray. """ if isinstance(value, float): return [value] @@ -32,15 +41,26 @@ def handle_single_float(cls, value: float | list | np.ndarray) -> list | np.ndar @pydantic.field_validator("electric_generation_mwh", mode="after") @classmethod def validate_greater_zero(cls, value: np.ndarray | list) -> np.ndarray | list: - """Handles case where we want a single value broadcast to the length - of the interval data. + """ + Handle case where we want a single value broadcast to the length of the interval data. + + Args: + value: The value to broadcast. + + Returns: + The value as a list or np.ndarray. """ assert np.array(value).min() >= 0.0 return value @pydantic.model_validator(mode="after") def create_idx(self) -> "RenewableGeneratorIntervalData": - """Creates an integer index.""" + """ + Create an integer index. + + Returns: + The instance with an index. + """ assert isinstance(self.electric_generation_mwh, (np.ndarray, list)) self.idx = np.arange(len(self.electric_generation_mwh)) return self @@ -76,20 +96,17 @@ class RenewableGeneratorOneInterval(AssetOneInterval): electric_generation_mwh: epl.LpVariable -class RenewableGenerator(epl.Asset): - """Renewable Generator asset. - - Handles optimization and plotting of results over many intervals. +class RenewableGenerator(epl.OptimizableAsset): + """ + The Renewable Generator asset can generate electricity based on an available amount of electricity. This asset is suitable for modelling either wind or solar generatation. The electricity generation can be controlled with relative or absolute upper and lower bounds on the available generation in an interval. - The upper bound for the electricity generation is limited by the - `electric_generation_mwh` interval data input in - `RenewableGenerator.optimize`. This input is the amount of generation - available from the wind or sun. + The upper bound for the electricity generation is limited by the `electric_generation_mwh` interval data + input in `RenewableGenerator.optimize`. This input is the amount of generation available from the wind or sun. The `electric_generation_mwh` input interval is the generation available to the site from the renewable resource - this should be after any limits or @@ -111,8 +128,27 @@ def __init__( name: str = "renewable-generator", freq_mins: int = defaults.freq_mins, constraints: "list[epl.Constraint] | list[dict] | None" = None, + include_spill: bool = False, + **kwargs: typing.Any, ) -> None: - """Initializes the asset.""" + """ + Initialize a Renewable Generator asset. + + Args: + electric_generation_mwh: Available electricity generation from the renewable source. + electricity_prices: The price of import electricity in each interval. + Will define both import and export prices if `export_electricity_prices` is None. + export_electricity_prices: The price of export electricity in each interval. + electricity_carbon_intensities: Carbon intensity of electricity in each interval. + electric_load_mwh: Electricity demand consumed by the site. + electric_generation_lower_bound_pct: Sets how much the generator can dump available + electricity. + name: The asset name. + freq_mins: length of the simulation intervals in minutes. + constraints: Additional custom constraints to apply to the linear program. + include_spill: Whether to include a spill asset in the site. + kwargs: Extra keyword arguments attempted to be used as custom interval data. + """ self.cfg = RenewableGeneratorConfig( name=name, electric_generation_lower_bound_pct=electric_generation_lower_bound_pct, @@ -123,7 +159,9 @@ def __init__( ) if electricity_prices is not None or electricity_carbon_intensities is not None: - assets = [self] + assets: list[epl.Asset] = [self] + if include_spill: + assets.append(epl.Spill()) self.site = epl.Site( assets=assets, @@ -133,16 +171,33 @@ def __init__( electricity_carbon_intensities=electricity_carbon_intensities, freq_mins=self.cfg.freq_mins, constraints=constraints, + **kwargs, ) def __repr__(self) -> str: - """A string representation of self.""" + """ + Create a string representation of self. + + Returns: + A string representation of self. + """ return "" def one_interval( self, optimizer: "epl.Optimizer", i: int, freq: "epl.Freq", flags: "epl.Flags" ) -> RenewableGeneratorOneInterval: - """Create asset data for a single interval.""" + """ + Create asset data for a single interval. + + Args: + optimizer: Linear program optimizer. + i: Integer index of the current interval. + freq: Interval frequency. + flags: Boolean flags to change simulation and results behaviour. + + Returns: + Linear program variables for a single interval. + """ name = f"i:{i},asset:{self.cfg.name}" assert isinstance(self.cfg.interval_data.electric_generation_mwh, np.ndarray) return RenewableGeneratorOneInterval( @@ -163,7 +218,16 @@ def constrain_within_interval( freq: "epl.Freq", flags: "epl.Flags", ) -> None: - """Constrain optimization within a single interval.""" + """ + Constrain asset within an interval. + + Args: + optimizer: Linear program optimizer. + ivars: Linear program variables. + i: Integer index of the current interval. + freq: Interval frequency. + flags: Boolean flags to change simulation and results behaviour. + """ pass def constrain_after_intervals( @@ -171,7 +235,13 @@ def constrain_after_intervals( optimizer: "epl.Optimizer", ivars: "epl.IntervalVars", ) -> None: - """Constrain asset after all intervals.""" + """ + Constrain asset after all intervals. + + Args: + optimizer: Linear program optimizer. + ivars: Linear program variables. + """ pass def optimize( @@ -181,7 +251,8 @@ def optimize( flags: Flags = Flags(), optimizer_config: "epl.OptimizerConfig | dict" = epl.optimizer.OptimizerConfig(), ) -> "epl.SimulationResult": - """Optimize the asset. + """ + Optimize the asset. Args: objective: the optimization objective - either "price" or "carbon". diff --git a/energypylinear/assets/site.py b/energypylinear/assets/site.py index db5ffab8..a578c2f6 100644 --- a/energypylinear/assets/site.py +++ b/energypylinear/assets/site.py @@ -17,32 +17,76 @@ from energypylinear.utils import repeat_to_match_length +def get_custom_interval_data(kwargs: dict | None) -> list | None: + """ + Attempts to extract custom interval data from keyword arguments. + + Args: + kwargs: Dictionary of potential custom interal data. + + Returns: + A list of custom interval data, or `None` if no kwargs. + """ + if (kwargs is None) or (kwargs == {}): + return None + + custom_interval_data = [] + for key, data in kwargs.items(): + # check if data is a list, nparry, tuple - sequence like + # could I check with the `typing.Sequence` type? + if data is not None and isinstance(data, (list, np.ndarray, tuple)): + custom_interval_data.append({"name": key, "data": data}) + return custom_interval_data + + def validate_interval_data( - assets: list, site: "epl.Site", repeat_interval_data: bool = True + assets: list, + site: "epl.Site", + custom_interval_data: dict | None = None, + repeat_interval_data: bool = True, ) -> None: """Validates asset interval data against the site.""" - if not repeat_interval_data: - for asset in assets: - if hasattr(asset.cfg, "interval_data"): - assert len(asset.cfg.interval_data.idx) == len( - site.cfg.interval_data.idx + cid = get_custom_interval_data(custom_interval_data) + + # sets the interval data of each asset to the same length as the site interval data + for asset in assets: + if hasattr(asset.cfg, "interval_data"): + if len(asset.cfg.interval_data.idx) != len(site.cfg.interval_data.idx): + idata = asset.cfg.interval_data.model_dump(exclude={"idx"}) + + for name, data in idata.items(): + assert isinstance(site.cfg.interval_data.idx, np.ndarray) + setattr( + asset.cfg.interval_data, + name, + repeat_to_match_length( + data, + site.cfg.interval_data.idx, + ) + if repeat_interval_data + else data, + ) + if repeat_interval_data: + setattr(asset.cfg.interval_data, "idx", site.cfg.interval_data.idx) + + if cid is not None: + for custom in cid: + if len(np.array(custom["data"]).shape) == 1: + setattr( + site.cfg.interval_data, + custom["name"], + repeat_to_match_length( + custom["data"], + np.array(site.cfg.interval_data.idx), + ) + if repeat_interval_data + else custom["data"], ) - else: - for asset in assets: - if hasattr(asset.cfg, "interval_data"): - if len(asset.cfg.interval_data.idx) != len(site.cfg.interval_data.idx): - idata = asset.cfg.interval_data.model_dump(exclude={"idx"}) - for name, data in idata.items(): - assert isinstance(site.cfg.interval_data.idx, np.ndarray) - setattr( - asset.cfg.interval_data, - name, - repeat_to_match_length( - data, - site.cfg.interval_data.idx, - ), - ) + # here really should check over all the interval data, not just the idx + for asset in assets: + if hasattr(asset.cfg, "interval_data"): + assert len(asset.cfg.interval_data.idx) == len(site.cfg.interval_data.idx) class SiteIntervalData(pydantic.BaseModel): @@ -52,14 +96,13 @@ class SiteIntervalData(pydantic.BaseModel): export_electricity_prices: np.ndarray | list[float] | float | None = None electricity_carbon_intensities: np.ndarray | list[float] | float | None = None gas_prices: np.ndarray | list[float] | float | None = None - electric_load_mwh: np.ndarray | list[float] | float | None = None high_temperature_load_mwh: np.ndarray | list[float] | float | None = None low_temperature_load_mwh: np.ndarray | list[float] | float | None = None low_temperature_generation_mwh: np.ndarray | list[float] | float | None = None idx: list[int] | np.ndarray = [] - model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True, extra="allow") @pydantic.model_validator(mode="after") def validate_all_things(self) -> "SiteIntervalData": @@ -284,16 +327,12 @@ def constrain_site_low_temperature_heat_balance( ) +# --8<-- [start:site] class Site: - """Site asset - handles optimization and plotting of many assets over many intervals. + """ + The Site asset can optimize many assets together in a single linear program. All assets are connected to the same site electricity, high and low temperature networks. - - All assets are optimized as a single linear program. - - Args: - assets: list[Asset] - a list of energypylinear assets to optimize together. - constraints: Additional custom constraints to apply to the linear program. """ def __init__( @@ -302,8 +341,8 @@ def __init__( electricity_prices: np.ndarray | list[float] | float | None = None, export_electricity_prices: np.ndarray | list[float] | float | None = None, electricity_carbon_intensities: np.ndarray | list[float] | float | None = None, - electric_load_mwh: np.ndarray | list[float] | float | None = None, gas_prices: np.ndarray | list[float] | float | None = None, + electric_load_mwh: np.ndarray | list[float] | float | None = None, high_temperature_load_mwh: np.ndarray | list[float] | float | None = None, low_temperature_load_mwh: np.ndarray | list[float] | float | None = None, low_temperature_generation_mwh: np.ndarray | list[float] | float | None = None, @@ -312,8 +351,29 @@ def __init__( import_limit_mw: float = 10000, export_limit_mw: float = 10000, constraints: "list[epl.Constraint] | list[dict] | None" = None, + **kwargs: typing.Any, ): - """Initialize a Site asset model.""" + """ + Initialize a Site. + + Args: + assets: Assets to optimize together. + electricity_prices: The price of import electricity in each interval. + Will define both import and export prices if `export_electricity_prices` is None. + export_electricity_prices: The price of export electricity in each interval. + electricity_carbon_intensities: Carbon intensity of electricity in each interval. + electric_load_mwh: Electricity demand consumed by the site. + gas_prices: Price of natural gas, used in CHP and boilers in each interval. + high_temperature_load_mwh: High temperature load of the site. + low_temperature_load_mwh: Low temperature load of the site. + name: The site name. + freq_mins: Size of an interval in minutes. + import_limit_mw: Maximum import power of the site. + export_limit_mw: Minimum import power of the site. + constraints: Additional custom constraints to apply to the linear program. + kwargs: Keyword arguments attempted to be used as extra interval data. + """ + # --8<-- [end:site] self.assets = assets self.cfg = SiteConfig( @@ -333,9 +393,12 @@ def __init__( freq_mins=freq_mins, ) - validate_interval_data(assets, self) + validate_interval_data(assets, self, custom_interval_data=kwargs) + + # TODO - should raise warning/error if kwargs get through - if there is a extra with that isn't made into interval data + # could check if attr of interval data, if not, raise warning - # TODO - these could go into the optimizer or something? + # TODO - these could go into the optimizer self.custom_constraints = constraints def __repr__(self) -> str: @@ -453,6 +516,7 @@ def optimize( self.optimizer.objective( epl.get_objective(objective, self.optimizer, ivars, self.cfg.interval_data) ) + self.objective = objective status = self.optimizer.solve( verbose=verbose, allow_infeasible=flags.allow_infeasible diff --git a/energypylinear/objectives.py b/energypylinear/objectives.py index 0ce30a05..aac73c1a 100644 --- a/energypylinear/objectives.py +++ b/energypylinear/objectives.py @@ -14,7 +14,8 @@ # --8<-- [start:term] @dataclasses.dataclass class Term: - """A simple term in the objective function. + """ + A simple term in the objective function. Will add `i` terms to the objective function, where `i` is the number of intervals in the simulation. @@ -29,7 +30,6 @@ class Term: ``` Examples: - ```python # an objective function term for site import power electricity cost Term( @@ -52,6 +52,7 @@ class Term: asset_type="battery", coefficient=0.25 ) + ``` Attributes: @@ -620,8 +621,16 @@ def get_objective( objective = CustomObjectiveFunction(terms=terms) - else: - assert isinstance(objective, CustomObjectiveFunction) + # TODO - add later - it's nice to have, but requires changing typing + # if isinstance(objective, list): + # # here assume the user has just put in the terms as a list, without the `{"terms": terms}` + # # doing it as I did it naturally when I used the library + # # TODO document + # objective = CustomObjectiveFunction( + # terms=[term_factory(term) for term in objective] + # ) + + assert isinstance(objective, CustomObjectiveFunction) obj: list[typing.Any | float] = [] add_simple_terms(interval_data, objective, ivars, obj) diff --git a/energypylinear/results/checks.py b/energypylinear/results/checks.py index 1f07aee6..294d9898 100644 --- a/energypylinear/results/checks.py +++ b/energypylinear/results/checks.py @@ -39,7 +39,7 @@ def check_electricity_balance( - simulation["total-electric_charge_mwh"] ) - balance = abs(inp + accumulation - out) < 1e-4 + balance = abs(inp + accumulation - out) < 1e-3 soc = simulation[[c for c in simulation.columns if "final_soc" in c]].sum(axis=1) debug = pd.DataFrame( diff --git a/energypylinear/results/extract.py b/energypylinear/results/extract.py index 58044b43..c7c402fd 100644 --- a/energypylinear/results/extract.py +++ b/energypylinear/results/extract.py @@ -64,18 +64,9 @@ def extract_site_results( assert isinstance(site.cfg.interval_data.electricity_prices, np.ndarray) assert isinstance(site.cfg.interval_data.electricity_carbon_intensities, np.ndarray) - for attr in [ - "electricity_prices", - "electricity_carbon_intensities", - "high_temperature_load_mwh", - "low_temperature_load_mwh", - "low_temperature_generation_mwh", - "gas_prices", - "electric_load_mwh", - ]: - results[f"{site.cfg.name}-{attr}"].append( - getattr(site.cfg.interval_data, attr)[i] - ) + for attr, data in site.cfg.interval_data.model_dump().items(): + if attr != "idx": + results[f"{site.cfg.name}-{attr}"].append(data[i]) def extract_spill_results(ivars: "epl.IntervalVars", results: dict, i: int) -> None: @@ -426,6 +417,13 @@ def extract_results( ) spill_occured = warn_spills(results, flags, verbose=verbose) + # TODO - could i put custom objective results here? + # would first just want the extra_interval_data + # its a bit tough with the complex terms + # maybe could just do simple ones / ones that fit a certain pattern??? + # better than nothing + # should be a flag though... + return SimulationResult( status=status, site=site, diff --git a/tests/assets/test_battery.py b/tests/assets/test_battery.py index 6b7542f9..2274d8cb 100644 --- a/tests/assets/test_battery.py +++ b/tests/assets/test_battery.py @@ -17,7 +17,7 @@ "electricity_prices, initial_charge_mwh, expected_dispatch", [ ([10, 10, 10], 0, [0, 0, 0]), - ([20, 10, 10], 6, [-4, -2, 0]), + ([20, 15, 10], 6, [-4, -2, 0]), ([10, 50, 10, 5000, 10], 0, [4, -4, 4, -4, 0]), ], ) @@ -48,6 +48,20 @@ def test_price_optimization( dispatch = charge - discharge np.testing.assert_almost_equal(dispatch, expected_dispatch) + # now try the same with a spill asset + # this is just for test coverage really... + asset = epl.Battery( + power_mw=power_mw, + capacity_mwh=capacity_mwh, + efficiency_pct=efficiency, + electricity_prices=np.array(electricity_prices), + freq_mins=freq_mins, + initial_charge_mwh=initial_charge_mwh, + final_charge_mwh=0, + include_spill=True, + ) + simulation = asset.optimize(verbose=False) + @pytest.mark.parametrize( "carbon_intensities, initial_charge_mwh, expected_dispatch", @@ -110,10 +124,17 @@ def check_no_simultaneous( df: pd.DataFrame, left_col: str, right_col: str ) -> tuple[bool, pd.DataFrame]: """Checks that we don't do two things at once.""" + + # checks = ( + # ((df[left_col] > 0) & (df[right_col] == 0)) + # | ((df[right_col] > 0) & (df[left_col] == 0)) + # | ((df[left_col] == 0) & (df[right_col] == 0)) + # ) + tol = 1e-8 checks = ( - ((df[left_col] > 0) & (df[right_col] == 0)) - | ((df[right_col] > 0) & (df[left_col] == 0)) - | ((df[left_col] == 0) & (df[right_col] == 0)) + ((df[left_col] > tol) & (df[right_col] <= tol)) + | ((df[right_col] > tol) & (df[left_col] <= tol)) + | ((df[left_col] <= tol) & (df[right_col] <= tol)) ) return ( checks.all(), @@ -195,6 +216,7 @@ def test_hypothesis( freq_mins=freq_mins, initial_charge_mwh=initial_charge_mwh, final_charge_mwh=final_charge_mwh, + include_spill=False, ) simulation = asset.optimize( @@ -314,7 +336,6 @@ def test_no_simultaneous_import_export() -> None: ) simulation = asset.optimize() results = simulation.results - check_no_simultaneous(results, "site-import_power_mwh", "site-export_power_mwh") diff --git a/tests/assets/test_chp.py b/tests/assets/test_chp.py index 9d672d91..7f9369de 100644 --- a/tests/assets/test_chp.py +++ b/tests/assets/test_chp.py @@ -17,6 +17,7 @@ def test_chp_gas_turbine_price() -> None: high_temperature_load_mwh=[20, 20, 1000], freq_mins=60, name="chp", + include_spill=True, ) simulation = asset.optimize() """ @@ -67,6 +68,7 @@ def test_chp_gas_turbine_carbon() -> None: gas_prices=20, high_temperature_load_mwh=[20, 20, 1000], freq_mins=60, + include_spill=True, ) simulation = asset.optimize( objective="carbon", @@ -122,6 +124,7 @@ def test_chp_gas_engine_price() -> None: 20.0, ], freq_mins=60, + include_spill=True, ) simulation = asset.optimize() """ @@ -151,6 +154,7 @@ def test_chp_gas_engine_carbon() -> None: high_temperature_load_mwh=[20.0, 20], low_temperature_load_mwh=[20.0, 20], freq_mins=60, + include_spill=True, ) simulation = asset.optimize( objective="carbon", diff --git a/tests/assets/test_heat_pump.py b/tests/assets/test_heat_pump.py index c7bcd7c8..5a4c79e2 100644 --- a/tests/assets/test_heat_pump.py +++ b/tests/assets/test_heat_pump.py @@ -44,6 +44,7 @@ def test_heat_pump_optimization_price() -> None: # which can be dumped or used by the heat pump to make high temperature heat high_temperature_load_mwh=100.0, low_temperature_generation_mwh=100.0, + include_spill=True, ) simulation = asset.optimize() results = simulation.results @@ -123,6 +124,7 @@ def test_heat_pump_optimization_carbon() -> None: gas_prices=gas_price, high_temperature_load_mwh=100, low_temperature_generation_mwh=100, + include_spill=True, ) simulation = asset.optimize(objective="carbon") @@ -151,6 +153,7 @@ def test_heat_pump_heat_balance() -> None: high_temperature_load_mwh=[1, 2.0, 4.0], low_temperature_generation_mwh=[100, 100, 100], include_valve=False, + include_spill=True, ) # limited by high temperature load @@ -169,6 +172,7 @@ def test_heat_pump_heat_balance() -> None: high_temperature_load_mwh=100, low_temperature_generation_mwh=[0.25, 0.5, 1.0], include_valve=False, + include_spill=True, ) simulation = asset.optimize( verbose=False, @@ -220,6 +224,7 @@ def test_heat_pump_hypothesis( high_temperature_load_mwh=100, low_temperature_generation_mwh=100, include_valve=include_valve, + include_spill=True, ) simulation = asset.optimize( verbose=False, diff --git a/tests/assets/test_renewable_generator.py b/tests/assets/test_renewable_generator.py index 0cbd49e7..568a7d91 100644 --- a/tests/assets/test_renewable_generator.py +++ b/tests/assets/test_renewable_generator.py @@ -123,6 +123,7 @@ def test_interval_data() -> None: electric_generation_lower_bound_pct=hypothesis.strategies.floats( min_value=0, max_value=1.0 ), + include_spill=hypothesis.strategies.booleans(), ) def test_hypothesis( idx_length: int, @@ -130,6 +131,7 @@ def test_hypothesis( prices_std: float, prices_offset: float, electric_generation_lower_bound_pct: float, + include_spill: bool, ) -> None: """Test optimization with hypothesis.""" electricity_prices = ( @@ -144,5 +146,6 @@ def test_hypothesis( electricity_prices=electricity_prices, electric_generation_mwh=electric_generation_mwh, electric_generation_lower_bound_pct=electric_generation_lower_bound_pct, + include_spill=include_spill, ) asset.optimize(verbose=False) diff --git a/tests/assets/test_site.py b/tests/assets/test_site.py index 44a8b3a6..aa0dd907 100644 --- a/tests/assets/test_site.py +++ b/tests/assets/test_site.py @@ -148,7 +148,7 @@ def test_interval_data() -> None: assets = [epl.RenewableGenerator(electric_generation_mwh=[1.0, 2.0])] validate_interval_data(assets, site) - # test that things work correctly when the assets have different length data as the site index + # test that things fail when the assets have different length data as the site index with pytest.raises(AssertionError): site = epl.Site(assets=[], electricity_carbon_intensities=[1.0, 2.0]) assets = [epl.RenewableGenerator(electric_generation_mwh=2.0)] diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000..e42aad04 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,39 @@ +"""Common utilities for testing.""" +import numpy as np + +import energypylinear as epl + +asset_names = ["battery", "evs", "chp", "heat-pump", "renewable"] + + +def get_assets(ds: dict, asset: str) -> list[epl.OptimizableAsset]: + """Helper function to get assets from a string.""" + assets: list = [] + library = { + "battery": epl.Battery( + power_mw=2, + capacity_mwh=4, + efficiency_pct=0.9, + **ds, + ), + "evs": epl.EVs(**ds, charger_turndown=0.0, charge_event_efficiency=1.0), + "chp": epl.CHP( + electric_power_max_mw=100, + electric_power_min_mw=50, + electric_efficiency_pct=0.2, + high_temperature_efficiency_pct=0.2, + low_temperature_efficiency_pct=0.2, + **ds, + ), + "heat-pump": epl.HeatPump( + **ds, + ), + "renewable": epl.RenewableGenerator( + electric_generation_mwh=np.random.uniform( + 0, 100, len(ds["electricity_prices"]) + ), + **ds, + ), + } + assets.append(library[asset]) + return assets diff --git a/tests/test_custom_objectives_complex_terms.py b/tests/test_complex_terms.py similarity index 100% rename from tests/test_custom_objectives_complex_terms.py rename to tests/test_complex_terms.py diff --git a/tests/test_custom_objectives.py b/tests/test_custom_objectives.py index 06c8805b..849a5700 100644 --- a/tests/test_custom_objectives.py +++ b/tests/test_custom_objectives.py @@ -1,5 +1,4 @@ -"""Tests the implementation of custom objective functions.""" - +"""Test that we can use custom objective functions.""" import numpy as np import pytest @@ -7,41 +6,7 @@ from energypylinear.data_generation import generate_random_ev_input_data from energypylinear.defaults import defaults from energypylinear.objectives import OneTerm - -asset_names = ["battery", "evs", "chp", "heat-pump", "renewable"] - - -def get_assets(ds: dict, asset: str) -> list[epl.Asset]: - """Helper function to get assets from a string.""" - assets: list = [] - library = { - "battery": epl.Battery( - power_mw=2, - capacity_mwh=4, - efficiency_pct=0.9, - electricity_prices=ds["electricity_prices"], - ), - "evs": epl.EVs(**ds, charger_turndown=0.0, charge_event_efficiency=1.0), - "chp": epl.CHP( - electric_power_max_mw=100, - electric_power_min_mw=50, - electric_efficiency_pct=0.2, - high_temperature_efficiency_pct=0.2, - low_temperature_efficiency_pct=0.2, - electricity_prices=ds["electricity_prices"], - ), - "heat-pump": epl.HeatPump( - electricity_prices=ds["electricity_prices"], - ), - "renewable": epl.RenewableGenerator( - electric_generation_mwh=np.random.uniform( - 0, 100, len(ds["electricity_prices"]) - ), - electricity_prices=ds["electricity_prices"], - ), - } - assets.append(library[asset]) - return assets +from tests.common import asset_names, get_assets def get_objective_terms() -> dict[str, list]: diff --git a/tests/test_extra_interval_data.py b/tests/test_extra_interval_data.py new file mode 100644 index 00000000..066665bd --- /dev/null +++ b/tests/test_extra_interval_data.py @@ -0,0 +1,172 @@ +"""Test that we can use custom interval data.""" +import numpy as np +import pytest + +import energypylinear as epl +from energypylinear.data_generation import generate_random_ev_input_data +from energypylinear.defaults import defaults +from tests.test_custom_objectives import asset_names, get_assets + + +def test_get_custom_interval_data() -> None: + """Test that we can pass in and use custom interval data with a Site.""" + + # TODO - should it be custom or custom...??? + + site = epl.Site( + assets=[ + epl.Battery( + power_mw=2, capacity_mwh=4, efficiency_pct=0.9, name="small-battery" + ), + epl.CHP( + electric_power_max_mw=50, + electric_efficiency_pct=0.4, + high_temperature_efficiency_pct=0.4, + name="gas-engine-chp", + ), + epl.Boiler(high_temperature_generation_max_mw=100), + epl.Spill(), + epl.Valve(), + ], + electricity_prices=[100, 1000, -20, 40, 45], + network_charge=[0, 300, 300, 0, 0], + interval_data=10, + not_interval_data="hello", + ) + assert hasattr(site.cfg.interval_data, "network_charge") + assert not hasattr(site.cfg.interval_data, "not_interval_data") + + # TODO - should raise error with the not_interval_data="hello" - an custom kwarg we cannot process + + objective = { + "terms": [ + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "electricity_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "electricity_prices", + "coefficient": -1, + }, + { + "asset_type": "*", + "variable": "gas_consumption_mwh", + "interval_data": "gas_prices", + }, + { + "asset_type": "site", + "variable": "import_power_mwh", + # here we use the custom / custom interval data + "interval_data": "network_charge", + }, + ] + } + sim = site.optimize(objective) + assert "site-network_charge" in sim.results.columns + + # below we check that the custom interval data is repeated + site = epl.Site( + assets=[ + epl.Battery( + power_mw=2, capacity_mwh=4, efficiency_pct=0.9, name="small-battery" + ), + epl.CHP( + electric_power_max_mw=50, + electric_efficiency_pct=0.4, + high_temperature_efficiency_pct=0.4, + name="gas-engine-chp", + ), + epl.Boiler(high_temperature_generation_max_mw=100), + epl.Spill(), + epl.Valve(), + ], + electricity_prices=[100, 1000, -20, 40, 45], + # network charge is too short, should fail - but only if we aren't trying to repeat + # instead could test current behaviour, which is to always repeat... + network_charge=[1, 300, 300, 0], + ) + sim = site.optimize(objective) + assert sim.results["site-network_charge"].tolist() == [1, 300, 300, 0, 1] + + # TODO - check we fail if we try to use custom interval data that isn't passed into the site init + + +@pytest.mark.parametrize("asset_name", asset_names) +def test_get_custom_interval_data_assets(asset_name: str) -> None: + """Test that we can pass in and use custom interval data with all the assets.""" + ds = generate_random_ev_input_data(48, n_chargers=3, charge_length=3, seed=None) + + ds["network_charge"] = np.zeros_like(ds["electricity_prices"]) + + # TODO - should just return a dict + assets = get_assets(ds, asset_name) + assert len(assets) == 1 + asset = assets[0] + assert asset.site is not None + assert hasattr(asset.site.cfg.interval_data, "network_charge") + + objective = [ + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "electricity_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "electricity_prices", + "coefficient": -1, + }, + { + "asset_type": "*", + "variable": "gas_consumption_mwh", + "interval_data": "gas_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "network_charge", + "coefficient": -1000, + }, + ] + + objective.extend( + [ + { + "asset_type": "spill", + "variable": variable, + "coefficient": defaults.spill_objective_penalty, + } + for variable in [ + "electric_generation_mwh", + "high_temperature_generation_mwh", + "electric_load_mwh", + "electric_charge_mwh", + "electric_discharge_mwh", + ] + ] + ) + objective.extend( + [ + { + "asset_type": "spill_evs", + "variable": variable, + "coefficient": defaults.spill_objective_penalty, + } + for variable in [ + "electric_generation_mwh", + "high_temperature_generation_mwh", + "electric_load_mwh", + "electric_charge_mwh", + "electric_discharge_mwh", + ] + ], + ) + sim = asset.optimize({"terms": objective}) + assert "site-network_charge" in sim.results.columns + + # TODO - check the export power - should be at site limit... + # but its a bit trikcy with the different assets... diff --git a/tests/test_network_charges.py b/tests/test_network_charges.py new file mode 100644 index 00000000..fb168a79 --- /dev/null +++ b/tests/test_network_charges.py @@ -0,0 +1,75 @@ +"""Test that we can simulate network charges. + +Should belong in the custom constraints tests really. +""" + +import numpy as np + +import energypylinear as epl + + +def test_network_charges() -> None: + """Test that we can simulate a network charge using extra interval data.""" + # first test nothing happens with no network charge + site = epl.Site( + assets=[ + epl.CHP( + electric_power_max_mw=200, + electric_efficiency_pct=1.0, + name="chp", + ), + ], + electricity_prices=np.zeros(5), + network_charge=[0, 0, 0, 0, 0], + electric_load_mwh=100, + ) + + objective = { + "terms": [ + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "electricity_prices", + }, + { + "asset_type": "site", + "variable": "export_power_mwh", + "interval_data": "electricity_prices", + "coefficient": -1, + }, + { + "asset_type": "*", + "variable": "gas_consumption_mwh", + "interval_data": "gas_prices", + }, + { + "asset_type": "site", + "variable": "import_power_mwh", + "interval_data": "network_charge", + "coefficient": 1000, + }, + ] + } + sim = site.optimize(objective, verbose=0) + assert all(sim.results["site-import_power_mwh"] == np.full(5, 100)) + assert all(sim.results["site-export_power_mwh"] == np.zeros(5)) + assert all(sim.results["chp-electric_generation_mwh"] == np.zeros(5)) + + # now change the network charge + # expect that we fire the generator + site = epl.Site( + assets=[ + epl.CHP( + electric_power_max_mw=200, + electric_efficiency_pct=1.0, + name="chp", + ), + ], + electricity_prices=np.zeros(5), + network_charge=[0, 300, 0, 0, 0], + electric_load_mwh=100, + ) + sim = site.optimize(objective, verbose=0) + assert all(sim.results["site-import_power_mwh"] == [100, 0, 100, 100, 100]) + assert all(sim.results["site-export_power_mwh"] == np.zeros(5)) + assert all(sim.results["chp-electric_generation_mwh"] == [0, 100, 0, 0, 0]) diff --git a/tests/test_plot.py b/tests/test_plot.py index 2df75f8e..150a8cf1 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -65,6 +65,7 @@ def test_chp_plot(tmp_path_factory: pytest.TempPathFactory) -> None: high_temperature_load_mwh=ht_load, low_temperature_load_mwh=lt_load, freq_mins=60, + include_spill=True, ) results = asset.optimize() @@ -93,6 +94,7 @@ def test_heat_pump_plot(tmp_path_factory: pytest.TempPathFactory) -> None: low_temperature_load_mwh=lt_load, low_temperature_generation_mwh=lt_gen, freq_mins=60, + include_spill=True, ) results = asset.optimize() diff --git a/tests/test_spill_warnings.py b/tests/test_spill_warnings.py index de519404..15d1b0fb 100644 --- a/tests/test_spill_warnings.py +++ b/tests/test_spill_warnings.py @@ -30,6 +30,7 @@ def test_chp_spill(capsys: CaptureFixture) -> None: 20, ], freq_mins=60, + include_spill=True, ) """ - high electricity price, low heat demand @@ -57,6 +58,7 @@ def test_chp_spill(capsys: CaptureFixture) -> None: 20, ], freq_mins=60, + include_spill=True, ) asset.optimize( flags=flags, @@ -75,6 +77,7 @@ def test_chp_spill(capsys: CaptureFixture) -> None: gas_prices=20, high_temperature_load_mwh=[20], freq_mins=60, + include_spill=True, ) asset.optimize() capture = capsys.readouterr() @@ -95,6 +98,7 @@ def test_evs_spill() -> None: [1, 0], [1, 0], ], + include_spill=True, ) simulation = asset.optimize() assert simulation.results["total-spills_mwh"].sum() > 0