Custom Objective Functions
In linear programming, the objective function defines the incentives and costs you want to optimize for.
energypylinear
has two different objective functions (price or carbon) built into the library. In addition, energypylinear
allows you to define your own, custom objective function.
A custom objective function allows you to construct an objective function that can optimize for the revenues and costs that are important to you.
Simple Objective Function Terms
Core to the custom objective function is the epl.Term
, which represents a single term in the objective function:
import dataclasses
@dataclasses.dataclass
class Term:
"""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.
This term will be represented in the objective function as:
```pseudocode
objective = []
for i in interval_data.idx:
term = variable * interval_data[i] * coefficient
objective.append(term)
```
Examples:
```python
# an objective function term for site import power electricity cost
Term(
variable="import_power_mwh",
asset_type="site",
interval_data="electricity_prices"
)
# an objective function term for site export power electricity revenue
Term(
variable="import_power_mwh",
asset_type="site",
interval_data="electricity_prices",
coefficient=-1
)
# an objective function term for battery cycle cost
Term(
variable="electric_charge_mwh",
asset_type="battery",
coefficient=0.25
)
```
Attributes:
variable: The linear program variable. This will be an
attribute of a OneInterval object, like `import_power_mwh`
or `gas_consumption_mwh`.
asset_type: The type of asset, such as `battery` or `chp`.
`*` will include all assets.
interval_data: The interval data variable, such as
`electricity_prices` or `gas_prices`.
coefficient: A constant multipler for the term.
type: The type of the term.
"""
variable: str
asset_type: str | None = None
interval_data: str | None = None
asset_name: str | None = None
coefficient: float = 1.0
type: typing.Literal["simple"] = "simple"
A term can target either many assets by type or one asset by name. It can also include multiplication by interval data or by a coefficient.
Custom Objective Function
A custom objective function is a list of terms:
OneTerm = Term | FunctionTermTwoVariables | FunctionTermManyVariables
@dataclasses.dataclass
class CustomObjectiveFunction:
"""A custom objective function - a sum of `OneTerm` objects."""
terms: list[OneTerm]
The objective function used in the linear program is the sum of these terms. They can be supplied as either a epl.Term
and epl.CustomObjectiveFunction
object or as a dictionaries.
Examples
Simultaneous Price and Carbon Optimization
energypylinear
has two different objective functions (price or carbon) built into the library - these optimize for either price or carbon, but not both at the same time.
This example shows how to optimize a battery for an objective that will optimize for both profit and emissions at the same time.
Below we create an objective function where we:
- reduce import when the electricity price or carbon intensity is high,
- increase export when the electricity price or carbon intensity is low.
Key to this is defining a carbon price, which allows us to convert our emissions into money:
import numpy as np
import energypylinear as epl
def simulate(
carbon_price: int, seed: int, n: int, verbose: int = 3
) -> epl.SimulationResult:
"""Run a battery simulation with a custom objective function."""
np.random.seed(seed)
site = epl.Site(
assets=[epl.Battery(power_mw=10, capacity_mwh=20)],
electricity_prices=np.random.normal(100, 1000, n),
electricity_carbon_intensities=np.clip(
np.random.normal(1, 10, n), a_min=0, a_max=None
),
)
return site.optimize(
objective=epl.CustomObjectiveFunction(
terms=[
epl.Term(
asset_type="site",
variable="import_power_mwh",
interval_data="electricity_prices",
),
epl.Term(
asset_type="site",
variable="export_power_mwh",
interval_data="electricity_prices",
coefficient=-1,
),
epl.Term(
asset_type="site",
variable="import_power_mwh",
interval_data="electricity_carbon_intensities",
coefficient=carbon_price,
),
epl.Term(
asset_type="site",
variable="export_power_mwh",
interval_data="electricity_carbon_intensities",
coefficient=-1 * carbon_price,
),
]
),
verbose=verbose,
)
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:
import pandas as pd
from rich import print
results = []
for carbon_price in range(0, 300, 50):
simulation = simulate(carbon_price=carbon_price, seed=42, n=72, verbose=3)
accounts = epl.get_accounts(simulation.results)
results.append(
{
"carbon_price": carbon_price,
"profit": f"{accounts.profit:5.2f}",
"emissions": f"{accounts.emissions:3.2f}",
}
)
print(pd.DataFrame(results))
carbon_price profit emissions
0 0 466212.61 161.16
1 50 452318.68 -579.51
2 100 390152.38 -1403.21
3 150 336073.24 -1848.94
4 200 290186.26 -2098.28
5 250 248371.70 -2288.42
As expected as our carbon price increases, both our profit and emissions decrease.
Renewables Certificates
In the previous example we used a custom objective function to apply incentives to the site import and export electricity by its asset type.
A custom objective function can also be used to apply incentives to a single asset by name.
An example of this is a renewable energy certificate scheme, where the generation from one asset receives additional income for each MWh generated.
In the example below, our solar
asset receives additional income for each MWh generated.
The site has a constrained export limit, which limits how much both generators can output. The site electric load increases in each interval, which allows us to see which generator is called first:
import energypylinear as epl
assets = [
epl.RenewableGenerator(
electric_generation_mwh=50,
name="wind",
electric_generation_lower_bound_pct=0.0,
),
epl.RenewableGenerator(
electric_generation_mwh=50,
name="solar",
electric_generation_lower_bound_pct=0.0,
),
]
site = epl.Site(
assets=assets,
electricity_prices=[250, 250, 250, 250, 250],
export_limit_mw=25,
electric_load_mwh=[0, 50, 75, 100, 300],
)
simulation = site.optimize(
verbose=3,
objective=epl.CustomObjectiveFunction(
terms=[
epl.Term(
asset_type="site",
variable="import_power_mwh",
interval_data="electricity_prices",
),
epl.Term(
asset_type="site",
variable="export_power_mwh",
interval_data="electricity_prices",
coefficient=-1,
),
epl.Term(
asset_name="solar",
variable="electric_generation_mwh",
coefficient=-25,
),
]
),
)
print(
simulation.results[
["solar-electric_generation_mwh", "wind-electric_generation_mwh"]
]
)
solar-electric_generation_mwh wind-electric_generation_mwh
0 25.0 0.0
1 50.0 25.0
2 50.0 50.0
3 50.0 50.0
4 50.0 50.0
As expected, the first generator that is called is the solar
generator, as it receives additional income for it's output.
As the site demand increases, the wind
generator is called to make up the remaining demand.
Synthetic PPA
A synthetic PPA is a financial instrument that allows swapping of the output of a wholesale exposed generator to a fixed price.
This can be modelled as a custom objective function.
In the example below, we model a site with wholesale exposed import and export, and swap the output of our wind
generator from the wholesale to a fixed price:
import numpy as np
import energypylinear as epl
np.random.seed(42)
n = 6
wind_mwh = np.random.uniform(0, 100, n)
electricity_prices = np.random.normal(0, 1000, n)
assets = [
epl.RenewableGenerator(
electric_generation_mwh=wind_mwh,
name="wind",
electric_generation_lower_bound_pct=0.0,
),
epl.Battery(power_mw=20, capacity_mwh=20),
]
site = epl.Site(assets=assets, electricity_prices=electricity_prices)
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_name": "wind",
"variable": "electric_generation_mwh",
"interval_data": "electricity_prices",
"coefficient": 1,
},
{
"asset_name": "wind",
"variable": "electric_generation_mwh",
"coefficient": -70
},
]
simulation = site.optimize(
verbose=4,
objective={"terms": terms},
)
print(simulation.results[["site-electricity_prices", "wind-electric_generation_mwh"]])
site-electricity_prices wind-electric_generation_mwh
0 1579.212816 37.454012
1 767.434729 95.071431
2 -469.474386 73.199394
3 542.560044 59.865848
4 -463.417693 15.601864
5 -465.729754 15.599452
As expected, our renewable generator still generates even during times of negative electricity prices - this is because its output is incentivized at a fixed, positive price.
Battery Cycle Cost
It's common in battery optimization to include a cost to use the battery - for every MWh of charge, some cost is incurred.
We can model this cost using a custom objective function, by applying a cost to discharging the battery:
import numpy as np
import energypylinear as epl
np.random.seed(42)
electricity_prices = np.random.normal(0, 1000, 48)
assets = [epl.Battery(power_mw=20, capacity_mwh=20)]
site = epl.Site(assets=assets, electricity_prices=electricity_prices)
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": "battery",
"variable": "electric_discharge_mwh",
"coefficient": 0.25
}
]
site.optimize(verbose=4, objective={"terms": terms})
You could also apply this cost to the battery electric charge, or to both the charge and discharge at the same time:
terms = [
{
"asset_type": "battery",
"variable": "electric_charge_mwh",
"coefficient": 0.25
},
{
"asset_type": "battery",
"variable": "electric_discharge_mwh",
"coefficient": 0.25
}
]
We can validate that this works by applying a stronger cycle cost and seeing the battery use decrease:
import pandas as pd
results = []
for cycle_cost in [0.25, 0.5, 1.0, 2.0]:
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": "battery",
"variable": "electric_discharge_mwh",
"interval_data": "electricity_prices",
"coefficient": cycle_cost,
},
]
simulation = site.optimize(verbose=4, objective={"terms": terms})
results.append(
{
"cycle_cost": cycle_cost,
"battery-electric_discharge_mwh": simulation.results[
"battery-electric_discharge_mwh"
].sum(),
}
)
print(pd.DataFrame(results))
As expected, as our cycle cost increases, our battery usage decreases.