Home PV installation with battery storage¶
To get in touch with basic functionalities in oemof.solph, we will use this tutorial to model the electricity supply of a single family household that intents to install a PV plant in combination with a battery storage. Therefore several steps are considered:
Step 1: Definition of the scenario
Step 2: Adding fixed size PV
Step 3: PV investment optimisation
Step 4: PV investment optimisation with existing battery
Step 5: Full investment optimisation
Step 6: Autarky of the system
Step 1: Definition of the scenario¶
Imagine you want to set up a PV plant on top of a single family house. How would you find out which system fits best and why? Here are some possible points to think about:
Which area (e.g. on the roof) can be covered and how large is it?
How much energy will you consume or be able to feed into the grid?
How much will you have to pay for the system?
Especially for single-family homes without energy storage, the load can be
very volatile: Switching on and off e.g. the oven can change the demand from
several 10 Watts to several kW. This is particularly important when alignment
of supply and demand. For this tutorial, we use hourly aggregated data from
real-world measurements
(pv_example_data.csv).
This can be seen as a compromise: A finer
resolution increases computatonal time but we want fast results in tutorial.
The full dataset (which has 10-minute resolution) is described in the paper
Dataset on electrical single-family house and heat pump load profiles in
Germany.
The PV time series has been created using
PVGIS.
First, let us import all needed packages:
os is handy to for file handling,
matplotlib is for plotting,
networkx as well as oemof.network.graph
can be used to visualise the energy system graph, and
pandas is for (input and output) data handling.
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
Now, let us load the input data and have an initial look at it. If the data provides time information, it is a good idea to directly consider it. This also allows to work with time stamps when handling the data. For illustation, we plot the first week of March 2020.
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
input_data.plot(drawstyle="steps")
plt.xlim(pd.Timestamp("2020-03-01 00:00"), pd.Timestamp("2020-03-07 00:00"))
plt.show()
The style has been chosen to reflect that the data is defined on the time intervals maked by the beginning time step. This means that 1.5 kW is probably not the peak load within the displayed week but only the peek hourly average. In contrast to the demand, the PV gain is given in normalised units and has to be multiplied by the installed PV capacity to yield the electricity production.
The model differentiates between time points and time steps.
As solph supports mixing differnt length of time steps in one model,
we need N+1 time points to define N time steps.
If the index does not contain the last time point,
the last interval can be infered based on index.freq.
Note
You need N+1 point in time to define N time steps.
The EnergySystem acts as a container.
You will need to add everything that is part of the energy system.
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
Now, to the actual energy system.
It is modelled in the form of a mathematical graph.
Energy flows along the edges (Flow) from node to node.
First of all, where is an electricity Bus, which is used as a central
point for the rest of the network to connect.
Secondly, there is a Sink to model the enery demand.
It takes an input from el_bus.
Note that we set nominal_capacity=1 as the time series
is already in kW.
While this can be considered as a hack,
it is common practice to do so:
In the model, it is no issue to run in overload.
As the last component, we add another Bus wich represents
the electricity grid.
Often, a Source would be used instead.
However, to emphasise that the same grid is used for purchasing electricity
and for feeding in, we use an nbalanced bus, also referred to as a “slack bus”.
el_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
el_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(el_bus, demand)
grid = solph.Bus(
label="grid",
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
energy_system.add(grid)
To obtain results, we have to create an optimisation model from the
EnergySystem and solve it.
To see diagnostic output, solve_kwargs={"tee": True} can be set,
by default (False) it will be silent.
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
If the model cannot find an optimal solution,
model.solve() will fail. You can try this, e.g. by setting
nominal_capacity=0.5 to the Flow between grid and el_bus
(the one that has the variable costs defined).
When the optimisation is finished,
we can proceed with evaluating the results.
tce = results["objective"]
print(f"The total annual costs are {tce:.2f} €.")
el_costs = 0.3 * results["flow"][(grid, el_bus)].sum()
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
flows = results["flow"]
baseline = np.zeros(len(flows))
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-01-01 00:00"), pd.Timestamp("2020-01-07 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-1_{mode}.svg")
plt.show()
At first, we look at the total costs.
As we used these for our objective value, it is directly accassible in the
meta_results.
Because we want to evaluate differnt contibutions later,
we also manually calculate the costs manually.
To no surprise the numbers are the same:
Quantity |
Unit |
Value |
|---|---|---|
Annual costs for grid electricity |
€ |
629.90 |
Total annual costs |
€ |
629.90 |
To have a look at the flows from and to the “electricity” bus.
You can use DataFrame.xs(...). For example
flows_from = flows.xs("electricity", axis=1, level=0, drop_level=False),
and with level=1 for flows entering the bus.
As grid supply is the only option, both lines of course overlap perfectly.
Learning
The model balances supply and demand along flows in a graph based model.
You can get the complete (uncommented) code for this step:
home_pv_1.py
Click to display the code
# -*- coding: utf-8 -*-
"""
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: Daniel Niederhöfer
SPDX-FileCopyrightText: DLR e.V.
SPDX-License-Identifier: MIT
"""
# %%[imports]
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
# %%[input_data]
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
input_data.plot(drawstyle="steps")
plt.xlim(pd.Timestamp("2020-03-01 00:00"), pd.Timestamp("2020-03-07 00:00"))
plt.show()
# %%[energy_system]
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
# %%[dispatch_model]
el_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
el_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(el_bus, demand)
grid = solph.Bus(
label="grid",
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
energy_system.add(grid)
# %%[graph_plotting]
plt.figure()
graph = energy_system.to_networkx()
nx.draw(graph, with_labels=True, font_size=8)
plt.show()
# %%[model_optimisation]
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
# %%[results]
tce = results["objective"]
print(f"The total annual costs are {tce:.2f} €.")
el_costs = 0.3 * results["flow"][(grid, el_bus)].sum()
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
flows = results["flow"]
baseline = np.zeros(len(flows))
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-01-01 00:00"), pd.Timestamp("2020-01-07 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-1_{mode}.svg")
plt.show()
Step 2: Adding fixed size PV¶
So far so good. But what would happen if we had an existing PV plant? Let us simply add one to the system by creating a suitable source. We might have five kW installed and therefore enter a nominal capacity of five.
pv_size = 5 # kW
pv_specific_costs = 1500 # €/kW
pv_lifetime = 20 # years
pv_system = solph.components.Source(
label="PV",
outputs={
el_bus: solph.Flow(
nominal_capacity=pv_size,
maximum=input_data["pv yield (kW/kW)"],
)
},
)
energy_system.add(pv_system)
Note that we set the PV yield time series using the max parameter:
It is always possible to not take the energy.
In our case, however, we want to allow feeding into the grid.
Thus, the corresponding Bus needs to accept an incoming Flow.
It is possible, to add Flow s to existing nodes:
grid = solph.Bus(
label="grid",
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
grid.inputs[el_bus] = solph.Flow(variable_costs=-0.06)
However, the Flow is typically directly added when defining the grid.
So, alternatively, you can just change the previos definition to look like:
grid = solph.Bus(
label="grid",
inputs={el_bus: solph.Flow(variable_costs=-0.06)},
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
Note that feeding into the grid will make sense if there is compensation
(or if the PV output is set to have a fix
production instead of a maximum one).
For our example, we take 6 ct/kWh as compensation for feed-in.
As building the PV system is free for the model (it is already present), we manually add an annuity to have the PV system included in the total costs.
pv_annuity = pv_size * pv_specific_costs / pv_lifetime
annual_grid_supply = results["flow"][(grid, el_bus)].sum()
el_costs = 0.3 * annual_grid_supply
el_revenue = 0.1 * results["flow"][(el_bus, grid)].sum()
tce = pv_annuity + results["objective"]
In this scenario, the objective value is negative, so a revenue is achieved. This is costly because of feeding in, demand and supply do hardly match in this scenario. However, correcting this by adding the annuity of the PV system leaves us with (positive) costs.
Quantity |
Unit |
Value (no PV) |
Value (5 kW PV) |
|---|---|---|---|
Annual costs for grid electricity |
€ |
629.90 |
365.32 |
Annual revenue from feed-in |
€ |
451.61 |
|
Annuity for the PV system |
€ |
375.00 |
|
Total annual costs |
€ |
629.90 |
479.03 |
Autarky |
% |
0.00 |
42.00 |
Learning
You can give negative costs to model a revenue.
You can get the complete (uncommented) code for this step:
home_pv_2.py
Click to display the code
# -*- coding: utf-8 -*-
"""
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: Daniel Niederhöfer
SPDX-FileCopyrightText: DLR e.V.
SPDX-License-Identifier: MIT
"""
# %%[imports]
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
# %%[input_data]
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
# %%[energy_system]
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
# %%[dispatch_model]
el_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
el_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(el_bus, demand)
# %%[grid_feedin]
grid = solph.Bus(
label="grid",
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
grid.inputs[el_bus] = solph.Flow(variable_costs=-0.06)
# %%[add_grid]
energy_system.add(grid)
# %%[pv_system]
pv_size = 5 # kW
pv_specific_costs = 1500 # €/kW
pv_lifetime = 20 # years
pv_system = solph.components.Source(
label="PV",
outputs={
el_bus: solph.Flow(
nominal_capacity=pv_size,
maximum=input_data["pv yield (kW/kW)"],
)
},
)
energy_system.add(pv_system)
# %%[graph_plotting]
plt.figure()
graph = energy_system.to_networkx()
nx.draw(graph, with_labels=True, font_size=8)
# %%[model_optimisation]
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
# %%[results]
pv_annuity = pv_size * pv_specific_costs / pv_lifetime
annual_grid_supply = results["flow"][(grid, el_bus)].sum()
el_costs = 0.3 * annual_grid_supply
el_revenue = 0.1 * results["flow"][(el_bus, grid)].sum()
tce = pv_annuity + results["objective"]
# %%[result_plotting]
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
print(f"The annual revenue from feed-in is {el_revenue:.2f} €.")
print(f"The annuity for the PV system is {pv_annuity:.2f} €.")
print(f"The total annual costs are {tce:.2f} €.")
annual_demand = input_data["electricity demand (kW)"].sum()
print(
f"Autarky is 1 - {annual_grid_supply:.2f} kWh / {annual_demand:.2f} kWh"
+ f" = {100 - 100 * annual_grid_supply / annual_demand:.2f} %."
)
flows = results["flow"]
baseline = np.zeros(len(flows))
plt.figure()
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
baseline + flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
baseline += flows[("grid", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("PV", "electricity")],
step="pre",
label="PV supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.step(
flows.index,
flows[("electricity", "demand")] + flows[("electricity", "grid")],
":",
color="darkgrey",
label="Feed-In",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-02-21 00:00"), pd.Timestamp("2020-02-28 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-2_{mode}.svg")
plt.show()
Step 3: PV investment optimisation¶
Now, compared to dispatch with a fixed PV plant, almost everything else stays the same, except for one thing: the nominal capacity of the PV plant. That’s because we want to find out which peak power the system should have to minimise our costs. Therefore an investment object with a periodical cost of 75 Euros per kW is assigned. These costs represent an assumed investment cost of 1500 Euros per kW divided by an estimated life time of 20 years as a straight-line deprecation. To make sure the model converges, we set a maximum capacity: If building the PV system was profitable, the optimiser would try to build an infinite size PV system, which would effectively prevent the model from converging.
pv_specific_costs = 1500 # €/kW
pv_lifetime = 20 # years
pv_epc = pv_specific_costs / pv_lifetime
pv_system = solph.components.Source(
label="PV",
outputs={
el_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=pv_epc, maximum=10),
maximum=input_data["pv yield (kW/kW)"],
)
},
)
energy_system.add(pv_system)
However, in this case we did not need to set the limit. The assumed annuity is so high that the costs of the produced electricity are above 6 ct/kWh. The resulting optimal PV size
# Potentially, there are more points in time to invest.
# For upfront invest, we need to select the initial.
pv_size = results["invest"][(pv_system, el_bus)][0]
is 4.13 kW, which is even smaller than what we tried before.
Quantity |
Unit |
Value |
|---|---|---|
Optimal PV size |
kW |
4.13 |
Annual costs for grid electricity |
€ |
376.59 |
Annual revenue from feed-in |
€ |
347.64 |
Annuity for the PV system |
€ |
309.40 |
Total annual costs |
€ |
477.41 |
Autarky |
% |
40.21 |
You can get the complete (uncommented) code for this step:
home_pv_3.py
Click to display the code
# -*- coding: utf-8 -*-
"""
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: Daniel Niederhöfer
SPDX-FileCopyrightText: DLR e.V.
SPDX-License-Identifier: MIT
"""
# %%[imports]
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
# %%[input_data]
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
# %%[energy_system]
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
# %%[dispatch_model]
el_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
el_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(el_bus, demand)
# %%[grid_conection]
grid = solph.Bus(
label="grid",
inputs={el_bus: solph.Flow(variable_costs=-0.06)},
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
# %%[add_grid]
energy_system.add(grid)
# %%[pv_system]
pv_specific_costs = 1500 # €/kW
pv_lifetime = 20 # years
pv_epc = pv_specific_costs / pv_lifetime
pv_system = solph.components.Source(
label="PV",
outputs={
el_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=pv_epc, maximum=10),
maximum=input_data["pv yield (kW/kW)"],
)
},
)
energy_system.add(pv_system)
# %%[graph_plotting]
plt.figure()
graph = energy_system.to_networkx()
nx.draw(graph, with_labels=True, font_size=8)
# %%[model_optimisation]
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
# %%[result_pv]
# Potentially, there are more points in time to invest.
# For upfront invest, we need to select the initial.
pv_size = results["invest"][(pv_system, el_bus)][0]
# %%[results]
pv_annuity = pv_epc * pv_size
annual_grid_supply = results["flow"][(grid, el_bus)].sum()
el_costs = 0.3 * annual_grid_supply
el_revenue = 0.1 * results["flow"][(el_bus, grid)].sum()
tce = results["objective"]
# %%[result_plotting]
print(f"The optimal PV size is {pv_size:.2f} kW.")
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
print(f"The annual revenue from feed-in is {el_revenue:.2f} €.")
print(f"The annuity for the PV system is {pv_annuity:.2f} €.")
print(f"The total annual costs are {tce:.2f} €.")
annual_demand = input_data["electricity demand (kW)"].sum()
print(
f"Autarky is 1 - {annual_grid_supply:.2f} kWh / {annual_demand:.2f} kWh"
+ f" = {100 - 100 * annual_grid_supply / annual_demand:.2f} %."
)
flows = results["flow"]
baseline = np.zeros(len(flows))
plt.figure()
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
baseline + flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
baseline += flows[("grid", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("PV", "electricity")],
step="pre",
label="PV supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.step(
flows.index,
flows[("electricity", "demand")] + flows[("electricity", "grid")],
":",
color="darkgrey",
label="Feed-In",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-02-21 00:00"), pd.Timestamp("2020-02-28 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-3_{mode}.svg")
plt.show()
Learning
You can use an Investment object for the nominal_capacity
to make the capacity an optimisation variable.
Step 4: PV investment optimisation with existing battery¶
In the previous step we learned that in our assumed scenario, compensation for feeding into the grid will not pay the PV system. So, what if we had a big battery? Let us just add one:
battery_specific_costs = 1000 # €/kW
battery_lifetime = 10 # years
battery_epc = battery_specific_costs / battery_lifetime
battery_size = 10 # kWh
battery = solph.components.GenericStorage(
label="Battery",
nominal_capacity=battery_size,
inputs={el_bus: solph.Flow()},
outputs={el_bus: solph.Flow()},
inflow_conversion_factor=0.9,
loss_rate=0.01,
)
energy_system.add(battery)
As before with the PV system, we manually add the deprecation of the battery to the objective value to get the total costs of the system.
As can be seen in the time series, the Battery allows to use much of the produced PV electricity. Thus, total PV size is not increased too much. With a battery that large, the it now dominates the total costs.
Quantity |
Unit |
Value |
|---|---|---|
Optimal PV size |
kW |
5.67 |
Annual costs for grid electricity |
€ |
72.56 |
Annual revenue from feed-in |
€ |
383.39 |
Annuity for the PV system |
€ |
425.12 |
Annuity for the battery |
€ |
1000.00 |
Total annual costs |
€ |
1267.65 |
Autarky |
% |
88.48 |
Learning
Energy storage is modelled using the GenericStorage class.
You can get the complete (uncommented) code for this step:
home_pv_4.py
Click to display the code
# -*- coding: utf-8 -*-
"""
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: Daniel Niederhöfer
SPDX-FileCopyrightText: DLR e.V.
SPDX-License-Identifier: MIT
"""
# %%[imports]
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
# %%[input_data]
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
# %%[energy_system]
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
# %%[dispatch_model]
el_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
el_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(el_bus, demand)
grid = solph.Bus(
label="grid",
inputs={el_bus: solph.Flow(variable_costs=-0.06)},
outputs={el_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
energy_system.add(grid)
pv_specific_costs = 1500 # €/kW
pv_lifetime = 20 # years
pv_epc = pv_specific_costs / pv_lifetime
pv_system = solph.components.Source(
label="PV",
outputs={
el_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=pv_epc, maximum=10),
maximum=input_data["pv yield (kW/kW)"],
)
},
)
energy_system.add(pv_system)
# %%[battery]
battery_specific_costs = 1000 # €/kW
battery_lifetime = 10 # years
battery_epc = battery_specific_costs / battery_lifetime
battery_size = 10 # kWh
battery = solph.components.GenericStorage(
label="Battery",
nominal_capacity=battery_size,
inputs={el_bus: solph.Flow()},
outputs={el_bus: solph.Flow()},
inflow_conversion_factor=0.9,
loss_rate=0.01,
)
energy_system.add(battery)
# %%[graph_plotting]
plt.figure()
graph = energy_system.to_networkx()
nx.draw(graph, with_labels=True, font_size=8)
# %%[model_optimisation]
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
# %%[results]
# Potentially, there are more points in time to invest.
# For upfront invest, we need to select the initial.
pv_size = results["invest"][(pv_system, el_bus)][0]
battery_annuity = battery_epc * battery_size
pv_annuity = pv_epc * results["invest"][(pv_system, el_bus)][0]
annual_grid_supply = results["flow"][(grid, el_bus)].sum()
el_costs = 0.3 * annual_grid_supply
el_revenue = 0.1 * results["flow"][(el_bus, grid)].sum()
tce = results["objective"] + battery_annuity
print(f"The optimal PV size is {pv_size:.2f} kW.")
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
print(f"The annual revenue from feed-in is {el_revenue:.2f} €.")
print(f"The annuity for the PV system is {pv_annuity:.2f} €.")
print(f"The annuity for the battery is {battery_annuity:.2f} €.")
print(f"The total annual costs are {tce:.2f} €.")
annual_demand = input_data["electricity demand (kW)"].sum()
print(
f"Autarky is 1 - {annual_grid_supply:.2f} kWh / {annual_demand:.2f} kWh"
+ f" = {100 - 100 * annual_grid_supply / annual_demand:.2f} %."
)
flows = results["flow"]
baseline = np.zeros(len(flows))
plt.figure()
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
baseline + flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
baseline += flows[("grid", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("PV", "electricity")],
step="pre",
label="PV supply",
)
baseline += flows[("PV", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("Battery", "electricity")],
step="pre",
label="Battery supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.step(
flows.index,
flows[("electricity", "demand")] + flows[("electricity", "Battery")],
"--",
color="darkgrey",
label="Battery charging",
)
plt.step(
flows.index,
flows[("electricity", "demand")]
+ flows[("electricity", "Battery")]
+ flows[("electricity", "grid")],
":",
color="darkgrey",
label="Feed-In",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-02-21 00:00"), pd.Timestamp("2020-02-28 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-4_{mode}.svg")
plt.show()
Step 5: Full investment optimisation¶
For a full investment optimisation, of course, we include optimising the
battery size. First, however, we will separate the PV system into
PV panels and inverter.
For clearity, we rename the former el_bus to ac_bus
and create a separate dc_bus.
As the numbers given in the input data already consider inverter losses,
we compensate for that by allowing higer DC gains from the panels,
the inverter then has a conversion_factor so that the Flow to the
AC bus is reduced by 5 %.
dc_bus = solph.Bus(label="DC")
pv_specific_costs = 1200 # €/kW
pv_lifetime = 20 # years
pv_epc = pv_specific_costs / pv_lifetime
pv_panels = solph.components.Source(
label="PV",
outputs={
dc_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=pv_epc, maximum=10),
maximum=input_data["pv yield (kW/kW)"] / 0.95,
)
},
)
inverter_specific_costs = 300 # €/kW
inverter_lifetime = 20 # years
inverter_epc = inverter_specific_costs / inverter_lifetime
inverter = solph.components.Converter(
label="inverter",
inputs={
dc_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=inverter_epc)
)
},
outputs={ac_bus: solph.Flow()},
conversion_factors={ac_bus: 0.95},
)
energy_system.add(dc_bus, pv_panels, inverter)
Now, the optimiser can choose on the inverter and number of PV panels separately. Note that the costs for inverter and pv panels add up to the value we used in the previous step. For the sake of simplicity in this tutorial, the battery will remain coupled to the AC system. We only let the optimisation decide on the size.
battery_specific_costs = 750 # €/kW
battery_lifetime = 20 # years
battery_epc = battery_specific_costs / battery_lifetime
battery_size = solph.Investment(ep_costs=battery_epc)
battery = solph.components.GenericStorage(
label="Battery",
nominal_capacity=battery_size,
inputs={ac_bus: solph.Flow()},
outputs={ac_bus: solph.Flow()},
inflow_conversion_factor=0.9,
loss_rate=0.01,
)
energy_system.add(battery)
We find that it is advisible to have an inverter that can only output two thirds of the nominal power of the PV panels. While the inverter is even smaller than in the previous steps, the total power of the panels is increased.
Quantity |
Unit |
Value |
|---|---|---|
Optimal PV size |
kW |
6.04 |
Optimal inverter size |
kW |
4.10 |
Optimal battery size |
kWh |
2.39 |
Annual costs for grid electricity |
€ |
156.35 |
Annual revenue from feed-in |
€ |
451.61 |
Annuity for the PV system |
€ |
362.27 |
Annuity for the battery |
€ |
89.53 |
Total annual costs |
€ |
398.66 |
Autarky |
% |
75.18 |
Learning
Converter s are used to model (possibly lossy) conversion.
You can get the complete (uncommented) code for this step:
home_pv_5.py
Click to display the code
# -*- coding: utf-8 -*-
"""
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: Daniel Niederhöfer
SPDX-FileCopyrightText: DLR e.V.
SPDX-License-Identifier: MIT
"""
# %%[imports]
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
# %%[input_data]
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
# %%[energy_system]
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
# %%[dispatch_model]
ac_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
ac_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(ac_bus, demand)
grid = solph.Bus(
label="grid",
inputs={ac_bus: solph.Flow(variable_costs=-0.06)},
outputs={ac_bus: solph.Flow(variable_costs=0.3)},
balanced=False,
)
energy_system.add(grid)
# %%[pv_system]
dc_bus = solph.Bus(label="DC")
pv_specific_costs = 1200 # €/kW
pv_lifetime = 20 # years
pv_epc = pv_specific_costs / pv_lifetime
pv_panels = solph.components.Source(
label="PV",
outputs={
dc_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=pv_epc, maximum=10),
maximum=input_data["pv yield (kW/kW)"] / 0.95,
)
},
)
inverter_specific_costs = 300 # €/kW
inverter_lifetime = 20 # years
inverter_epc = inverter_specific_costs / inverter_lifetime
inverter = solph.components.Converter(
label="inverter",
inputs={
dc_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=inverter_epc)
)
},
outputs={ac_bus: solph.Flow()},
conversion_factors={ac_bus: 0.95},
)
energy_system.add(dc_bus, pv_panels, inverter)
# %%[battery]
battery_specific_costs = 750 # €/kW
battery_lifetime = 20 # years
battery_epc = battery_specific_costs / battery_lifetime
battery_size = solph.Investment(ep_costs=battery_epc)
battery = solph.components.GenericStorage(
label="Battery",
nominal_capacity=battery_size,
inputs={ac_bus: solph.Flow()},
outputs={ac_bus: solph.Flow()},
inflow_conversion_factor=0.9,
loss_rate=0.01,
)
energy_system.add(battery)
# %%[graph_plotting]
plt.figure()
graph = energy_system.to_networkx()
nx.draw(graph, with_labels=True, font_size=8)
# %%[model_optimisation]
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
# %%[results]
# Potentially, there are more points in time to invest.
# For upfront invest, we need to select the initial.
pv_size = results["invest"][(pv_panels, dc_bus)][0]
pv_annuity = pv_epc * pv_size
inverter_size = results["invest"][(dc_bus, inverter)][0]
inverter_annuity = inverter_epc * inverter_size
battery_size = results["invest"][battery][0]
battery_annuity = battery_epc * battery_size
annual_grid_supply = results["flow"][(grid, ac_bus)].sum()
el_costs = 0.3 * annual_grid_supply
el_revenue = 0.1 * results["flow"][(ac_bus, grid)].sum()
tce = results["objective"]
print(f"The optimal PV size is {pv_size:.2f} kW.")
print(f"The optimal inverter size is {inverter_size:.2f} kW.")
print(f"The optimal battery size is {battery_size:.2f} kWh.")
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
print(f"The annual revenue from feed-in is {el_revenue:.2f} €.")
print(f"The annuity for the PV system is {pv_annuity:.2f} €.")
print(f"The annuity for the battery is {battery_annuity:.2f} €.")
print(f"The total annual costs are {tce:.2f} €.")
annual_demand = input_data["electricity demand (kW)"].sum()
print(
f"Autarky is 1 - {annual_grid_supply:.2f} kWh / {annual_demand:.2f} kWh"
+ f" = {100 - 100 * annual_grid_supply / annual_demand:.2f} %."
)
flows = results["flow"]
baseline = np.zeros(len(flows))
plt.figure()
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
baseline + flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
baseline += flows[("grid", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("inverter", "electricity")],
step="pre",
label="PV supply",
)
baseline += flows[("inverter", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("Battery", "electricity")],
step="pre",
label="Battery supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.step(
flows.index,
flows[("electricity", "demand")] + flows[("electricity", "Battery")],
"--",
color="darkgrey",
label="Battery charging",
)
plt.step(
flows.index,
flows[("electricity", "demand")]
+ flows[("electricity", "Battery")]
+ flows[("electricity", "grid")],
":",
color="darkgrey",
label="Feed-In",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-02-21 00:00"), pd.Timestamp("2020-02-28 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-5_{mode}.svg")
plt.show()
Step 6: Autarky of the system¶
The final step of the tutorial shows a way how to limit the energy supply from the grid in order to guarantee a minimum autarky grade of our system consisting of a combined PV and battery storage investment.
Since our total energy demand is almost 2100 kWh, we could accomplish an autarky grade of 90 percent if we limited the sum of thegrid energy supply to 210 kWh. Choosing 42 kW as a reasonable dimensioned power, multiplying it by five full load hours does the trick.
grid = solph.Bus(
label="grid",
inputs={ac_bus: solph.Flow(variable_costs=-0.06)},
outputs={
ac_bus: solph.Flow(
nominal_capacity=42,
full_load_time_max=5,
variable_costs=0.3,
)
},
balanced=False,
)
energy_system.add(grid)
So, finally let us compare the results from all different steps.
Quantity |
Unit |
Value (no PV) |
Value (5 kW PV) |
Value (opt. PV) |
Value (big bat.) |
Value (all opt.) |
Value (Autarky) |
|---|---|---|---|---|---|---|---|
Optimal PV size |
kW |
4.13 |
5.67 |
6.04 |
10 |
||
Optimal inverter size |
kW |
4.1 |
6.79 |
||||
Optimal battery size |
kWh |
2.39 |
5.01 |
||||
Annual costs for grid electricity |
€ |
629.9 |
365.32 |
376.59 |
72.56 |
156.35 |
63 |
Annual revenue from feed-in |
€ |
451.61 |
347.64 |
383.39 |
451.61 |
821.78 |
|
Annuity for the PV system |
€ |
375 |
309.4 |
425.12 |
362.27 |
600 |
|
Annuity for the battery |
€ |
1000.00 |
89.53 |
188.02 |
|||
Total annual costs |
€ |
629.9 |
479.03 |
477.41 |
1267.65 |
398.66 |
459.76 |
Autarky |
% |
0 |
42 |
40.21 |
88.48 |
75.18 |
90 |
Learning
You can define the full load time of a Flow
to limit its energy transfer.
Here, this has been used to accieve a minimum autarky.
You can get the complete (uncommented) code for this step:
home_pv_6.py
Click to display the code
# -*- coding: utf-8 -*-
"""
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: Daniel Niederhöfer
SPDX-FileCopyrightText: DLR e.V.
SPDX-License-Identifier: MIT
"""
# %%[imports]
import os
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from oemof import solph
# %%[input_data]
file_path = os.path.dirname(__file__)
filename = os.path.join(file_path, "pv_example_data.csv")
input_data = pd.read_csv(
filename, index_col="timestep", parse_dates=["timestep"]
)
# %%[energy_system]
energy_system = solph.EnergySystem(
timeindex=input_data.index,
infer_last_interval=True,
)
# %%[dispatch_model]
ac_bus = solph.Bus(label="electricity")
demand = solph.components.Sink(
label="demand",
inputs={
ac_bus: solph.Flow(
nominal_capacity=1,
fix=input_data["electricity demand (kW)"],
)
},
)
energy_system.add(ac_bus, demand)
# %%[grid]
grid = solph.Bus(
label="grid",
inputs={ac_bus: solph.Flow(variable_costs=-0.06)},
outputs={
ac_bus: solph.Flow(
nominal_capacity=42,
full_load_time_max=5,
variable_costs=0.3,
)
},
balanced=False,
)
energy_system.add(grid)
# %%[pv_system]
dc_bus = solph.Bus(label="DC")
pv_specific_costs = 1200 # €/kW
pv_lifetime = 20 # years
pv_epc = pv_specific_costs / pv_lifetime
pv_panels = solph.components.Source(
label="PV",
outputs={
dc_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=pv_epc, maximum=10),
maximum=input_data["pv yield (kW/kW)"] / 0.95,
)
},
)
inverter_specific_costs = 300 # €/kW
inverter_lifetime = 20 # years
inverter_epc = inverter_specific_costs / inverter_lifetime
inverter = solph.components.Converter(
label="inverter",
inputs={
dc_bus: solph.Flow(
nominal_capacity=solph.Investment(ep_costs=inverter_epc)
)
},
outputs={ac_bus: solph.Flow()},
conversion_factors={ac_bus: 0.95},
)
energy_system.add(dc_bus, pv_panels, inverter)
# %%[battery]
battery_specific_costs = 750 # €/kW
battery_lifetime = 20 # years
battery_epc = battery_specific_costs / battery_lifetime
battery_size = solph.Investment(ep_costs=battery_epc)
battery = solph.components.GenericStorage(
label="Battery",
nominal_capacity=battery_size,
inputs={ac_bus: solph.Flow()},
outputs={ac_bus: solph.Flow()},
inflow_conversion_factor=0.9,
loss_rate=0.01,
)
energy_system.add(battery)
# %%[graph_plotting]
plt.figure()
graph = energy_system.to_networkx()
nx.draw(graph, with_labels=True, font_size=8)
# %%[model_optimisation]
model = solph.Model(energy_system)
results = model.solve(solver="cbc", solve_kwargs={"tee": True})
# %%[results]
pv_size = results["invest"][(pv_panels, dc_bus)][0]
pv_annuity = pv_epc * pv_size
inverter_size = results["invest"][(dc_bus, inverter)][0]
inverter_annuity = inverter_epc * inverter_size
battery_size = results["invest"][battery][0]
battery_annuity = battery_epc * battery_size
annual_grid_supply = results["flow"][(grid, ac_bus)].sum()
el_costs = 0.3 * annual_grid_supply
el_revenue = 0.1 * results["flow"][(ac_bus, grid)].sum()
tce = results["objective"]
print(f"The optimal PV size is {pv_size:.2f} kW.")
print(f"The optimal inverter size is {inverter_size:.2f} kW.")
print(f"The optimal battery size is {battery_size:.2f} kWh.")
print(f"The annual costs for grid electricity are {el_costs:.2f} €.")
print(f"The annual revenue from feed-in is {el_revenue:.2f} €.")
print(f"The annuity for the PV system is {pv_annuity:.2f} €.")
print(f"The annuity for the battery is {battery_annuity:.2f} €.")
print(f"The total annual costs are {tce:.2f} €.")
annual_demand = input_data["electricity demand (kW)"].sum()
print(
f"Autarky is 1 - {annual_grid_supply:.2f} kWh / {annual_demand:.2f} kWh"
+ f" = {100 - 100 * annual_grid_supply / annual_demand:.2f} %."
)
flows = results["flow"]
baseline = np.zeros(len(flows))
plt.figure()
mode = "light"
# mode = "dark"
if mode == "dark":
plt.style.use("dark_background")
plt.fill_between(
flows.index,
baseline,
baseline + flows[("grid", "electricity")],
step="pre",
label="Grid supply",
)
baseline += flows[("grid", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("inverter", "electricity")],
step="pre",
label="PV supply",
)
baseline += flows[("inverter", "electricity")]
plt.fill_between(
flows.index,
baseline,
baseline + flows[("Battery", "electricity")],
step="pre",
label="Battery supply",
)
plt.step(
flows.index,
flows[("electricity", "demand")],
"-",
color="darkgrey",
label="Electricity demand",
)
plt.step(
flows.index,
flows[("electricity", "demand")] + flows[("electricity", "Battery")],
"--",
color="darkgrey",
label="Battery charging",
)
plt.step(
flows.index,
flows[("electricity", "demand")]
+ flows[("electricity", "Battery")]
+ flows[("electricity", "grid")],
":",
color="darkgrey",
label="Feed-In",
)
plt.legend()
plt.ylabel("Power (kW)")
plt.xlim(pd.Timestamp("2020-02-21 00:00"), pd.Timestamp("2020-02-28 00:00"))
plt.gcf().autofmt_xdate()
plt.savefig(f"home_pv_result-6_{mode}.svg")
plt.show()