Note

You can download this example as a Jupyter notebook or start it in interactive mode.

Creating Variables#

Variables are created and assigned to the model using the function

model.add_variables

where model is a linopy.Model instance. In the following we show how this function works and what the resulting variables look like. So, let’s create a model and go through it!

[1]:
import numpy as np
import pandas as pd
import xarray as xr

from linopy import Model

m = Model()

First of all it is crucial to know, that the return value of the .add_variables function is a linopy.Variable which itself contains all important information and provides helpful functions. It can have an arbitrary number of labeled dimensions. For each combination of coordinates, exactly one representative scalar variable is defined and, in the end, passed to the solver.

The first three arguments of the .add_variables function are 1. lower denoting the lower bound of the variables (default -inf) 2. upper denoting the upper bound (default +inf) 3. coords (default None).

These argument determine the shape of the added variable.

Generally, the function is strongly aligned to the initialization of an xarray.DataArray. Therefore lower and upper can be

  • scalar values (int/float)

  • numpy ndarray’s

  • pandas Series

  • pandas DataFrame’s

  • xarray DataArray’s

Note that scalars, numpy objects and pandas objects do not have or do not require dimension names. Thus, the naming of the dimensions is done by xarray. Therefore you can pass the coords argument, or alternatively, a dims argument in order to name your dimensions.

Hint

It is best practice to always define variables with explicit name and dimension names. This eases the inspection and avoids confusion from the automatically derived names.

Let’s start by creating a simple variable:

If we just keep the default, which is -inf and +inf for lower and upper, the code returns

[2]:
x = m.add_variables(name="x")
x
[2]:
Variable
--------
x ∈ [-inf, inf]

which is a variable without any coordinates and with just one optimization variable. The variable name is set by name = 'x'.

Like this the variable appears with its name when defining expression with it:

[3]:
x + 5
[3]:
LinearExpression
----------------
+1 x + 5

We can alter the lower and upper bounds of the variable by assigning scalar values to them.

[4]:
y = m.add_variables(lower=0, upper=4, name="y")

Variable Types#

Per default the variable type is continuous, that the variables can take any real value in between and including the lower and upper bound. In order to alter the type, you have the option to set integer or binary to True.

[5]:
m.add_variables(lower=0, upper=10, integer=True)
[5]:
Variable
--------
var0 ∈ Z ⋂ [0,...,10]

Note

Since we did not set the name argument the variable name is automatically determined and set to var0.

This variable var0 can take all integer number between 0 and 10 inclusively. On the other hand, when defining a binary variable, we do not specify the lower and upper bounds and set binary to true.

[6]:
m.add_variables(binary=True)
[6]:
Variable
--------
var1 ∈ {0, 1}

Working with dimensions#

When initializing dimensional variables, it is most straight-forward and recommended to create variables with DataArray’s which are passed to the as lower and/or upper.

[7]:
lower = xr.DataArray([1, 2, 3])
v = m.add_variables(lower, name="v")
v
[7]:
Variable (dim_0: 3)
-------------------
[0]: v[0] ∈ [1, inf]
[1]: v[1] ∈ [2, inf]
[2]: v[2] ∈ [3, inf]

The returned Variable now has the same shape as the lower bound that we passed to the initialization. Since we did not specify any dimension name, it defaults to dim_0. In order to give the dimension a proper name we can use the dims argument.

[8]:
lower = xr.DataArray([1, 2, 3], dims=["my-dim"])
m.add_variables(lower)
[8]:
Variable (my-dim: 3)
--------------------
[0]: var2[0] ∈ [1, inf]
[1]: var2[1] ∈ [2, inf]
[2]: var2[2] ∈ [3, inf]

You can arbitrarily broadcast dimensions when passing DataArray’s with different set of dimensions. Let’s do it and give lower another dimension than upper:

[9]:
lower = xr.DataArray([1, 2, 3], dims=["my-dim"])
upper = xr.DataArray([10, 11, 12, 13], dims=["my-dim-2"])
m.add_variables(lower, upper)
[9]:
Variable (my-dim: 3, my-dim-2: 4)
---------------------------------
[0, 0]: var3[0, 0] ∈ [1, 10]
[0, 1]: var3[0, 1] ∈ [1, 11]
[0, 2]: var3[0, 2] ∈ [1, 12]
[0, 3]: var3[0, 3] ∈ [1, 13]
[1, 0]: var3[1, 0] ∈ [2, 10]
[1, 1]: var3[1, 1] ∈ [2, 11]
[1, 2]: var3[1, 2] ∈ [2, 12]
[1, 3]: var3[1, 3] ∈ [2, 13]
[2, 0]: var3[2, 0] ∈ [3, 10]
[2, 1]: var3[2, 1] ∈ [3, 11]
[2, 2]: var3[2, 2] ∈ [3, 12]
[2, 3]: var3[2, 3] ∈ [3, 13]

Now instead of a single dimension, we end up with two dimensions my-dim and my-dim-2 in the variable. This kind of broadcasting is a deeply incorporated in the functionality of linopy.

We recall that, in order to improve the inspection, it is encouraged to define a name when creating a variable. So in your model you would rather write something like:

[10]:
lower = xr.DataArray([1, 2, 3], dims=["time"])
upper = xr.DataArray([10, 11, 12, 13], dims=["station"])
m.add_variables(lower, upper, name="supply")
[10]:
Variable (time: 3, station: 4)
------------------------------
[0, 0]: supply[0, 0] ∈ [1, 10]
[0, 1]: supply[0, 1] ∈ [1, 11]
[0, 2]: supply[0, 2] ∈ [1, 12]
[0, 3]: supply[0, 3] ∈ [1, 13]
[1, 0]: supply[1, 0] ∈ [2, 10]
[1, 1]: supply[1, 1] ∈ [2, 11]
[1, 2]: supply[1, 2] ∈ [2, 12]
[1, 3]: supply[1, 3] ∈ [2, 13]
[2, 0]: supply[2, 0] ∈ [3, 10]
[2, 1]: supply[2, 1] ∈ [3, 11]
[2, 2]: supply[2, 2] ∈ [3, 12]
[2, 3]: supply[2, 3] ∈ [3, 13]

Initializing variables with numpy arrays#

If lower and upper are numpy arrays, linopy it is recommended to pass a dims or a coords argument.

[11]:
lower = np.array([1, 2])
upper = np.array([10, 10])
m.add_variables(lower, upper, dims=["my-dim"])
[11]:
Variable (my-dim: 2)
--------------------
[0]: var4[0] ∈ [1, 10]
[1]: var4[1] ∈ [2, 10]

This is equivalent to the following

[12]:
my_dim = pd.RangeIndex(2, name="my-dim")
lower = np.array([1, 2])
upper = np.array([10, 10])
m.add_variables(lower, upper, coords=[my_dim])
[12]:
Variable (my-dim: 2)
--------------------
[0]: var5[0] ∈ [1, 10]
[1]: var5[1] ∈ [2, 10]

Note that

  • dims is a list of string defining the dimension names.

  • coords is an tuple of indexes as expected by xarray.DataArray.

  • The shape of lower and upper is aligned with coords.

  • When defining the index for the coords, a name was set in the index creation. This is helpful as we can ensure which dimension the variable is defined on.

Let’s make the same example without setting an explicit dimension name:

[13]:
coords = (pd.RangeIndex(2),)
m.add_variables(lower=lower, coords=coords)
[13]:
Variable (dim_0: 2)
-------------------
[0]: var6[0] ∈ [1, inf]
[1]: var6[1] ∈ [2, inf]

The dimension is now called dim_0, any new assignment of variable without dimension names, will also use that dimension name. When combining the variables to expressions it is important that you make sure that dimension names represent what they should.

Hint

If you want to make sure, you are not messing up with dimensions, create the model with the flag force_dim_names = True, i.e.

[14]:
other = Model(force_dim_names=True)
try:
    other.add_variables(lower=lower, coords=coords)
except ValueError as e:
    print("This raised an error:", e)
This raised an error: Added data contains non-customized dimension names. This is not allowed when setting `force_dim_names` to True.

Initializing variables with Pandas objects#

Pandas objects always have indexes but do not require dimension names. It is again helpful to ensure that the variable have explicit dimension names, when passing lower and upper without coords. This can be done by either passing the dims argument to the .add_variables function, i.e.

[15]:
lower = pd.Series([1, 1])
upper = pd.Series([10, 12])
m.add_variables(lower, upper, dims=["my-dim"])
[15]:
Variable (my-dim: 2)
--------------------
[0]: var7[0] ∈ [1, 10]
[1]: var7[1] ∈ [1, 12]

or naming the indexes and columns of the pandas objects directly, e.g.

[16]:
lower = pd.Series([1, 1]).rename_axis("my-dim")
upper = pd.Series([10, 12]).rename_axis("my-dim")
m.add_variables(lower, upper)
[16]:
Variable (my-dim: 2)
--------------------
[0]: var8[0] ∈ [1, 10]
[1]: var8[1] ∈ [1, 12]

Note

Again, if lower and upper do not have the same dimension names, the arrays are broadcasted, meaning the dimensions are spanned:

[17]:
lower = pd.Series([1, 1]).rename_axis("my-dim")
upper = pd.Series([10, 12]).rename_axis("my-other-dim")
m.add_variables(lower, upper)
[17]:
Variable (my-dim: 2, my-other-dim: 2)
-------------------------------------
[0, 0]: var9[0, 0] ∈ [1, 10]
[0, 1]: var9[0, 1] ∈ [1, 12]
[1, 0]: var9[1, 0] ∈ [1, 10]
[1, 1]: var9[1, 1] ∈ [1, 12]

Now instead of 2 variables, 4 variables were defined.

The similar bahvior accounts for the case when passing a DataFrame and a Series without dimension names. The index axis is the first axis of both objects, thus these are expected to be the same (Note that pandas convention, is that Series are aligned and broadcasted along the column dimension of DataFrames):

[18]:
lower = pd.DataFrame([[1, 1, 2], [1, 2, 2]])
upper = pd.Series([10, 12])
m.add_variables(lower, upper)
[18]:
Variable (dim_0: 2, dim_1: 3)
-----------------------------
[0, 0]: var10[0, 0] ∈ [1, 10]
[0, 1]: var10[0, 1] ∈ [1, 10]
[0, 2]: var10[0, 2] ∈ [2, 10]
[1, 0]: var10[1, 0] ∈ [1, 12]
[1, 1]: var10[1, 1] ∈ [2, 12]
[1, 2]: var10[1, 2] ∈ [2, 12]

Again, one is always safer when explicitly naming the dimensions:

[19]:
lower = lower.rename_axis(index="my-dim", columns="my-other-dim")
upper = upper.rename_axis("my-dim")
m.add_variables(lower, upper)
[19]:
Variable (my-dim: 2, my-other-dim: 3)
-------------------------------------
[0, 0]: var11[0, 0] ∈ [1, 10]
[0, 1]: var11[0, 1] ∈ [1, 10]
[0, 2]: var11[0, 2] ∈ [2, 10]
[1, 0]: var11[1, 0] ∈ [1, 12]
[1, 1]: var11[1, 1] ∈ [2, 12]
[1, 2]: var11[1, 2] ∈ [2, 12]
**New in version 0.3.6**

As pandas objects always have indexes, the `coords` argument is not required and is ignored is passed. Before, it was used to overwrite the indexes of the pandas objects. A warning is raised if `coords` is passed and if these are not aligned with the pandas object.
[20]:
unaligned_coords = pd.Index([1, 2]), pd.Index([2, 3, 4])
m.add_variables(lower, upper, coords=unaligned_coords)
/home/docs/checkouts/readthedocs.org/user_builds/linopy/envs/latest/lib/python3.12/site-packages/linopy/common.py:154: UserWarning: coords for dimension(s) ['my-dim', 'my-other-dim'] is not aligned with the pandas object. Previously, the indexes of the pandas were ignored and overwritten in these cases. Now, the pandas object's coordinates are taken considered for alignment.
  warn(
/home/docs/checkouts/readthedocs.org/user_builds/linopy/envs/latest/lib/python3.12/site-packages/linopy/common.py:154: UserWarning: coords for dimension(s) ['my-dim'] is not aligned with the pandas object. Previously, the indexes of the pandas were ignored and overwritten in these cases. Now, the pandas object's coordinates are taken considered for alignment.
  warn(
[20]:
Variable (my-dim: 2, my-other-dim: 3)
-------------------------------------
[0, 0]: var12[0, 0] ∈ [1, 10]
[0, 1]: var12[0, 1] ∈ [1, 10]
[0, 2]: var12[0, 2] ∈ [2, 10]
[1, 0]: var12[1, 0] ∈ [1, 12]
[1, 1]: var12[1, 1] ∈ [2, 12]
[1, 2]: var12[1, 2] ∈ [2, 12]

Masking Arrays#

In some cases, you want to create a variable with given dimensions, but not all parts should be active.

For example, think about an set of ports between which goods can be transported. However, a port cannot transport goods to itself. For such a case, you would create an variable transport which has the dimension (from, to) with values on the diagonal disabled.

Therefore, you can pass a mask argument which has False values on the diagonal and True elsewhere.

[21]:
ports = list("abcdef")
port_from = pd.Index(ports, name="from")
port_to = pd.Index(ports, name="to")

mask = np.ones((len(ports), len(ports)), dtype=bool)
np.fill_diagonal(mask, False)
mask
[21]:
array([[False,  True,  True,  True,  True,  True],
       [ True, False,  True,  True,  True,  True],
       [ True,  True, False,  True,  True,  True],
       [ True,  True,  True, False,  True,  True],
       [ True,  True,  True,  True, False,  True],
       [ True,  True,  True,  True,  True, False]])
[22]:
transport = m.add_variables(
    lower=0, coords=[port_from, port_to], name="transport", mask=mask
)
transport
[22]:
Variable (from: 6, to: 6) - 6 masked entries
--------------------------------------------
[a, a]: None
[a, b]: transport[a, b] ∈ [0, inf]
[a, c]: transport[a, c] ∈ [0, inf]
[a, d]: transport[a, d] ∈ [0, inf]
[a, e]: transport[a, e] ∈ [0, inf]
[a, f]: transport[a, f] ∈ [0, inf]
[b, a]: transport[b, a] ∈ [0, inf]
                ...
[e, f]: transport[e, f] ∈ [0, inf]
[f, a]: transport[f, a] ∈ [0, inf]
[f, b]: transport[f, b] ∈ [0, inf]
[f, c]: transport[f, c] ∈ [0, inf]
[f, d]: transport[f, d] ∈ [0, inf]
[f, e]: transport[f, e] ∈ [0, inf]
[f, f]: None

Now the diagonal values, for example at the variable at [a,a], are None.

Accessing assigned variables#

All variables added to the model are stored in the .variables container.

[23]:
m.variables
[23]:
linopy.model.Variables
----------------------
 * x
 * y
 * var0
 * var1
 * v (dim_0)
 * var2 (my-dim)
 * var3 (my-dim, my-dim-2)
 * supply (time, station)
 * var4 (my-dim)
 * var5 (my-dim)
 * var6 (dim_0)
 * var7 (my-dim)
 * var8 (my-dim)
 * var9 (my-dim, my-other-dim)
 * var10 (dim_0, dim_1)
 * var11 (my-dim, my-other-dim)
 * var12 (my-dim, my-other-dim)
 * transport (from, to)

You can always access the variables from the .variables container either by get-item, i.e.

[24]:
m.variables["x"]
[24]:
Variable
--------
x ∈ [-inf, inf]

or by attribute accessing

[25]:
m.variables.x
[25]:
Variable
--------
x ∈ [-inf, inf]