Quickstart 3 - Investments & Storage¶
Problem Description¶
A data centre in Seville, Spain, has a constant demand of 100 MW. The operator considers investing in on-site solar PV and battery storage to reduce reliance on grid electricity, which is priced at 120 €/MWh. The investment costs and characteristics of the components are as follows:
| Component | Overnight Cost | Lifetime | Discount Rate |
|---|---|---|---|
| Solar PV | 400 €/kW | 25 years | 5% |
| Battery Storage | 150 €/kWh | 25 years | 5% |
| Battery Inverter | 170 €/kW | 10 years | 5% |
- The battery storage system has a round-trip efficiency of 90% and an energy-to-power ratio of 4 hours.
- The solar PV plant has a capacity factor time series given here.
- Assume that feeding electricity into the grid is not allowed.
Find the least-cost investment in solar PV and battery storage to cover the load. What is the average cost per unit of electricity consumed? How much electricity is consumed from the grid and when? How is the battery operated?
PyPSA Solution¶
We start by creating a new network with a single bus and the data centre load. We also add a generator to model supply from the grid priced at 120 €/MWh. These are the fixed components of the network.
import numpy as np
import pandas as pd
import pypsa
from pypsa.costs import annuity
n = pypsa.Network()
n.add("Bus", "seville")
n.add("Load", "demand", bus="seville", p_set=100)
n.add("Generator", "grid", bus="seville", p_nom=100, marginal_cost=120, carrier="grid");
Next, we read in the capacity factor time series of the network, which covers hourly data for the year 2011.
p_max_pu = pd.read_csv(
"https://model.energy/data/time-series-f17c3736a2719ce7da58484180d89e2d.csv",
index_col=0,
parse_dates=True,
)["solar"]
p_max_pu[7:15]
time 2011-01-01 07:00:00 0.000 2011-01-01 08:00:00 0.000 2011-01-01 09:00:00 0.048 2011-01-01 10:00:00 0.118 2011-01-01 11:00:00 0.214 2011-01-01 12:00:00 0.243 2011-01-01 13:00:00 0.219 2011-01-01 14:00:00 0.138 Name: solar, dtype: float64
We need to tell PyPSA that these are the snapshots (time steps) we want to optimise over.
n.set_snapshots(p_max_pu.index)
len(n.snapshots)
8760
Then, we add the solar PV with the availability time series as p_max_pu, the annualised costs in €/MW/a as capital_cost and mark the component as extendable with p_nom_extendable.
n.add(
"Generator",
"solar",
bus="seville",
p_max_pu=p_max_pu,
capital_cost=annuity(0.05, 25) * 400_000,
p_nom_extendable=True,
carrier="solar",
);
Similarly, we add the battery storage. Here, we need to take extra care with the multiple cost components of the battery system for the capital_cost, and the energy-to-power ratio (max_hours).
cc_inverter = annuity(0.05, 25) * 170_000
cc_storage = annuity(0.05, 25) * 150_000
n.add(
"StorageUnit",
"battery",
bus="seville",
capital_cost=cc_inverter + 4 * cc_storage,
p_nom_extendable=True,
carrier="battery",
efficiency_store=np.sqrt(0.9),
efficiency_dispatch=np.sqrt(0.9),
max_hours=4,
);
Now, the model is ready to be solved:
n.optimize()
/tmp/ipykernel_3871/1261279110.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['seville'], dtype='object', name='name')
WARNING:pypsa.consistency:The following generators have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['grid', 'solar'], dtype='object', name='name')
WARNING:pypsa.consistency:The following storage_units have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['battery'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io:Writing objective.
Writing constraints.: 0%| | 0/14 [00:00<?, ?it/s]
Writing constraints.: 100%|██████████| 14/14 [00:00<00:00, 126.90it/s]
Writing constraints.: 100%|██████████| 14/14 [00:00<00:00, 125.76it/s]
Writing continuous variables.: 0%| | 0/6 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 6/6 [00:00<00:00, 296.32it/s]
INFO:linopy.io: Writing time: 0.15s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 43802 primals, 105122 duals Objective: 4.83e+07 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-energy_balance were not assigned to the network.
('ok', 'optimal')
To retrieve the optimised capacities, we can either directly access the p_nom_opt attribute of the components, or use the n.statistics module.
display(n.generators.p_nom_opt)
display(n.storage_units.p_nom_opt)
name grid 100.000000 solar 659.778414 Name: p_nom_opt, dtype: float64
name battery 351.371839 Name: p_nom_opt, dtype: float64
n.statistics.optimal_capacity()
component carrier
Generator grid 100.00000
solar 659.77841
StorageUnit battery 351.37184
dtype: float64
The statistics module also provides a convenient way to calculate investment and operational costs, as well as the average cost per unit of electricity consumed.
totex = {"opex": n.statistics.opex(), "capex": n.statistics.capex()}
pd.concat(totex, axis=1).div(1e6).round(2) # M€/a
| opex | capex | ||
|---|---|---|---|
| component | carrier | ||
| Generator | grid | 10.36 | NaN |
| solar | NaN | 18.73 | |
| StorageUnit | battery | NaN | 19.20 |
(n.statistics.capex().sum() + n.statistics.opex().sum()) / 100 / 8760 # €/MWh
np.float64(55.1190371277968)
The statistics module can also give you the energy balances of the system, to see how much electricity is consumed from the grid and when, and what the battery storage losses are.
n.statistics.energy_balance().div(1e3) # GWh
component carrier bus_carrier
Generator grid AC 86.354010
solar AC 836.363402
Load - AC -876.000000
StorageUnit battery AC -46.717412
dtype: float64
To access and plot the state of charge profile of the battery for January, run
n.storage_units_t.state_of_charge.loc["2011-01"].plot(backend="plotly")
The statistics functions also have built-in plotting capabilities, e.g. to plot the dispatch profiles of the system as stacked area charts.
n.add(
"Carrier",
["grid", "solar", "battery", "AC"],
color=["blue", "yellow", "green", "k"],
)
n.statistics.energy_balance.iplot()
Finally, any network object can be exported to files, for example to Excel or NetCDF, for further analysis or reporting. Importing from files is of course also possible.
n.export_to_excel("data-centre-investment.xlsx")
n.export_to_netcdf("data-centre-investment.nc")
o = pypsa.Network("data-centre-investment.nc")
WARNING:pypsa.network.io:Excel file data-centre-investment.xlsx does not exist, creating it
INFO:pypsa.network.io:Exported network 'Unnamed Network' saved to 'data-centre-investment.xlsx contains: buses, generators, storage_units, carriers, sub_networks, loads
INFO:pypsa.network.io:Exported network 'Unnamed Network' saved to 'data-centre-investment.nc contains: buses, generators, storage_units, carriers, sub_networks, loads
INFO:pypsa.network.io:Imported network 'Unnamed Network' has buses, carriers, generators, loads, storage_units, sub_networks
Find many more extensive examples in the examples section.
The user guide section contains detailed information on architecture, components, problem formulation and utilities.