A first model

After you have successfully installed IESopt, you can start to build your first model. In this tutorial, we will show you how to create a simple model, solve it, and extract some basic results.

Let’s start by creating a Python file, e.g. main.py, to hold the necessary code, and add the following lines:

import iesopt

# Load and solve a model.
model = iesopt.run("my_first_model.iesopt.yaml")

print("Objective value:", model.objective_value)

Next we’ll describe a simple model that we would like to solve and set various parameters. Create an empty file my_first_model.iesopt.yaml (later on multiple files can be combined to describe more complex models), that we can now use to actually describe the model.

Model configuration

The first part of each *.iesopt.yaml file describes general configuration parameters. Add the following lines:

config:
  optimization:
    problem_type: LP
    snapshots:
      count: 24

This tells IESopt to start building a model, while expecting all formulations to be representable as LP (so, adding binary variables will cause an error). Further we specify how many time steps (called Snapshot) we’d like to use, here telling IESopt that we are looking to optimize a full day (by the default a Snapshot’s duration is one hour).

Energy carriers

Since IESopt is a general purpose energy system model, it does not restrict you to a set of predefined types of energy, but rather expects you to first define those. For our first model, we’ll only care about electricity and gas, so we add the following lines:

carriers:
  electricity: {}
  gas: {}

The {} represents an empty dictionary. Additional parameters related to the carrier could later be specified there.

Model components

Now that all general settings of the model are in place, we can actually start describing the model’s structure:

  • A photovoltaic (a Profile) system is feeding in electricity (based on some external availability factor) into a local electricity grid (a Node called elec_grid)

  • A simple storage is connected (via a Connection) to this grid, able to shift energy between time steps

  • An endogenous demand (electricity) must be met at every time step

  • Any uncovered demand (by PV or storage) can be satisfied using a gasturbine (a Unit), that draws gas from a gas_grid, that needs to buy all used gas from a gas_market

We now describe these seven components, starting with the electricity grid Node. Add the following lines to my_first_model.iesopt.yaml - everything after a # is considered a comment by IESopt:

components:
  elec_grid:                # the unique name of this component
    type: Node              # the type of this component
    carrier: electricity    # this (Node-specific) parameter fixes the carrier to be electricity

Head over to this section of the docs to read up on the different component types that are available.

We add the other two Nodes, making sure, that the storage is stateful (models a “state of charge”), and that the gas_grid has the proper carrier. Make sure that you do get the correct indent, since all of the following lines still belong to the overall components: definition:

  gas_grid:                 # the unique name of this component
    type: Node              # the type of this component
    carrier: gas            # this (Node-specific) parameter fixes the carrier to be electricity

  storage:
    type: Node
    carrier: electricity
    has_state: true         # this allows this Node to have an "internal state"
    state_lb: 0             # the state can not drop below 0
    state_ub: 50            # a maximum of 50 electricity can be stored

Two important things can be seen with the storage:

  1. Values in IESopt do not carry an explicit unit (at the moment, this will be possible in the future). This means, that if we consider electricity to be in kW/kWh in this model, we need to make sure that all settings are adjusted to match that.

  2. We are implicitly using a default setting of a parameter that we did not specify: state_cyclic (check it out in the docs!) is set to eq per default, forcing the model to always end the optimization with as much “charge” in the storage as it started with in the first time step (however, how much that is, is left to the optimizer to decide).

Now that we have all Nodes in place, we can insert the only Connection by adding:

  conn:
    type: Connection
    node_from: elec_grid    # energy flows from HERE
    node_to: storage        # to THERE
    capacity: 15            # with a maximum capacity of +-15 units of electricity

Notice that while we did specify the Nodes that are connected by this Connection, no energy carrier was explicitly set. This is due to the fact that Connections infer the energy carrier and will automatically fail if they connect two Nodes with a different type of energy. The specified capacity therefore refers to 15 units of electricity and constructs symmetric bounds on the flow (again, read up in the docs for other options and asymmetric bounds).

Next, it’s time to add all three Profiles to the model. A Profile allows for the “creation” or “destruction” of energy: Normally, all energy needs to move through the model (possibly being transformed), but can not enter/leave the model. This is where Profiles help, representing for example the cost of buying gas (gas_market) or a fixed demand that needs to be covered (demand).

We now add:

  demand:
    type: Profile           # the type is now "Profile"
    carrier: electricity
    value: 5                # this models a fixed demand of 5 units of electricity during every Snapshot
    node_from: elec_grid    # this tells MFC that this Profile draws energy from "elec_grid"

  # We can also set the "value" of a Profile to a time series, as can be seen:
  photovoltaic:
    type: Profile
    carrier: electricity
    value: [0,0,0,0,0,1,2,3,4,5,8,12,12,12,8,5,4,3,2,1,0,0,0,0]
    node_to: elec_grid      # now feeding INTO "elec_grid"

  gas_market:
    type: Profile
    carrier: gas
    mode: create            # this changes the mode from the default ("fixed") to "create"
    cost: 100               # this specifies the "cost of gas"
    node_to: gas_grid

Note on setting Profile values: While the value of a Profile can be set directly in the *.iesopt.yaml file, most of the time this will just result in a convoluted file. It’s therefore possible to load external data files (in CSV format) and directly link to them using a simple column@filename syntax, that can be seen in other examples.

While the first two Profiles should be mostly self-explanatory, the gas_market introduces a new concept: While standard Profiles always consider a fixed value (a time series), some time series may not be exogenous, e.g. how much gas is bought from the gas market. That’s where the mode: create setting helps by defining a Profile that can freely choose (as long as the value is >= 0) how much gas is being bought, but associates every unit of gas with a cost that has to be “paid” (this is therefore automatically inserted into the objective function).

The only thing missing from the model description is the Unit (gas_turbine). It takes gas as its only input and transforms that into electricity. For this we first add the following component:

  gasturbine:
    type: Unit
    inputs: {gas: gas_grid}
    outputs: {electricity: elec_grid}
    conversion: 1 gas -> 0.40 electricity
    capacity: 100 out:electricity

Let’s look at the Unit-specific settings in detail:

  • inputs: {gas: gas_grid}: This tells IESopt that an input accepting gas (the carrier) is connected to gas_grid (= it is consuming gas from there).

  • Similarly, outputs: {electricity: elec_grid} tells IESopt where the only output (with carrier electricity) feeds energy to.

  • The most important part is kept in the so-called “conversion expression” conversion: 1 gas -> 0.40 electricity: This tells IESopt that the Unit will use 1 unit of gas (e.g. kWh, MJ, …) and convert it into 0.4 units of electricity (e.g. kWh) at a fixed rate.

  • Finally, capacity: 100 out:electricity specifies the “capacity limitations” of this Unit: 100 units of electricity can be produced. This implicitly limits the maximum amount of gas that can be used during a time step to 250 units of gas.

Units come with a lot of additional (and very specific) parameters (e.g. marginal_cost, availability, …) that are explained in detail in the specific section of the docs.

Final config file

Following the above steps you should now have your my_first_model.iesopt.yaml config file setup like this:

config:
  optimization:
    problem_type: LP
    snapshots:
      count: 24

carriers:
  electricity: {}
  gas: {}

components:
  elec_grid:                # the unique name of this component
    type: Node              # the type of this component
    carrier: electricity    # this (Node-specific) parameter fixes the carrier to be electricity

  gas_grid:                 # the unique name of this component
    type: Node              # the type of this component
    carrier: gas            # this (Node-specific) parameter fixes the carrier to be electricity

  storage:
    type: Node
    carrier: electricity
    has_state: true         # this allows this Node to have an "internal state"
    state_lb: 0             # the state can not drop below 0
    state_ub: 50            # a maximum of 50 electricity can be stored    
   
  conn:
    type: Connection
    node_from: elec_grid    # energy flows from HERE
    node_to: storage        # to THERE
    capacity: 15            # with a maximum capacity of +-15 units of electricity

  demand:
    type: Profile           # the type is now "Profile"
    carrier: electricity
    value: 5                # this models a fixed demand of 5 units of electricity during every Snapshot
    node_from: elec_grid    # this tells MFC that this Profile draws energy from "elec_grid"

  # We can also set the "value" of a Profile to a time series, as can be seen:
  photovoltaic:
    type: Profile
    carrier: electricity
    value: [0,0,0,0,0,1,2,3,4,5,8,12,12,12,8,5,4,3,2,1,0,0,0,0]
    node_to: elec_grid      # now feeding INTO "elec_grid"

  gas_market:
    type: Profile
    carrier: gas
    mode: create            # this changes the mode from the default ("fixed") to "create"
    cost: 100               # this specifies the "cost of gas"
    node_to: gas_grid

  gasturbine:
    type: Unit
    inputs: {gas: gas_grid}
    outputs: {electricity: elec_grid}
    conversion: 1 gas -> 0.40 electricity
    capacity: 100 out:electricity

Running the optimization

Assuming that both my_first_model.iesopt.yaml and main.py are located in the same folder, you can now execute the following command there (make sure you are in the correct Python environment):

python ./main.py

The output should show a total objective value of 9500, resulting from 38 units of electricity missing after accounting for PV production, amounting to a total need of 95 units of gas, at a price of 100.

Note on startup time: If you are running this for the first time, you might notice a considerable delay before the output is shown. This is due to the fact that IESopt is automatically connecting to the internal “core” (which is written in Julia) and updating it. This can be avoided by running the import iesopt command once and then just iterating on the generate/optimize part, in a “REPL-style” approach. Remember: Interactively executing a line or block of code in VSCode is usually bound to Shift + Enter.

Extracting model results

Now, head over to the extracting results tutorial, to get started with extracting actually results from your model.