Note
You can download this example as a Jupyter notebook or start it in interactive mode.
Creating Expressions#
In this notebook, we look at different options to create expressions. A strong focus will be set on the array-like operations: Since variables are represented in array-like structure, we benefit from a lot of well-knwon functionalities which we know from numpy, pandas or xarray.
These are for example
arithmeticoperations to create expressionsbroadcastingto combine smaller and larger arrays.locto select a subset of the original array using indexes.whereto select where the variable or expression should be active or not.shiftto shift the whole array along one dimension.groupbyto group by a key and apply operations on the groups.rollingto perform a rolling operation and perform operations
Hint
Nearly all of the functions and properties, that can be accessed from a Variable, can be accesses from a LinearExpression and QuadraticExpression.
Let’s start by creating a model.
[1]:
import pandas as pd
import xarray as xr
import linopy
linopy.options["semantics"] = "v1"
time = pd.Index(range(10), name="time")
port = pd.Index(list("abcd"), name="port")
m = linopy.Model()
x = m.add_variables(lower=0, coords=[time], name="x")
y = m.add_variables(lower=0, coords=[time, port], name="y")
m
[1]:
Linopy LP model
===============
Variables:
----------
* x (time)
* y (time, port)
Expressions:
------------
<empty>
Constraints:
------------
<empty>
Status:
-------
initialized
Arithmetic Operations#
Arithmetic operations such as addition (+), subtraction (-), multiplication (*) can be used directly on the variables and expressions in Linopy. These operations are applied element-wise on the variables.
For example, if you want to create a new combined expr z that is the sum of x and y, you can do so as follows:
[2]:
z = x + y
z
[2]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: +1 x[0] + 1 y[0, a]
[0, b]: +1 x[0] + 1 y[0, b]
[0, c]: +1 x[0] + 1 y[0, c]
[0, d]: +1 x[0] + 1 y[0, d]
[1, a]: +1 x[1] + 1 y[1, a]
[1, b]: +1 x[1] + 1 y[1, b]
[1, c]: +1 x[1] + 1 y[1, c]
...
[8, b]: +1 x[8] + 1 y[8, b]
[8, c]: +1 x[8] + 1 y[8, c]
[8, d]: +1 x[8] + 1 y[8, d]
[9, a]: +1 x[9] + 1 y[9, a]
[9, b]: +1 x[9] + 1 y[9, b]
[9, c]: +1 x[9] + 1 y[9, c]
[9, d]: +1 x[9] + 1 y[9, d]
Note
In the addition, the variable x is broadcasted and the return value has the same set of dimensions as y.
Similarly, you can subtract y from x or multiply x and y as follows:
[3]:
z = x - y
z
[3]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: +1 x[0] - 1 y[0, a]
[0, b]: +1 x[0] - 1 y[0, b]
[0, c]: +1 x[0] - 1 y[0, c]
[0, d]: +1 x[0] - 1 y[0, d]
[1, a]: +1 x[1] - 1 y[1, a]
[1, b]: +1 x[1] - 1 y[1, b]
[1, c]: +1 x[1] - 1 y[1, c]
...
[8, b]: +1 x[8] - 1 y[8, b]
[8, c]: +1 x[8] - 1 y[8, c]
[8, d]: +1 x[8] - 1 y[8, d]
[9, a]: +1 x[9] - 1 y[9, a]
[9, b]: +1 x[9] - 1 y[9, b]
[9, c]: +1 x[9] - 1 y[9, c]
[9, d]: +1 x[9] - 1 y[9, d]
[4]:
z = x * y
z
[4]:
QuadraticExpression [time: 10, port: 4]:
----------------------------------------
[0, a]: +1 x[0] y[0, a]
[0, b]: +1 x[0] y[0, b]
[0, c]: +1 x[0] y[0, c]
[0, d]: +1 x[0] y[0, d]
[1, a]: +1 x[1] y[1, a]
[1, b]: +1 x[1] y[1, b]
[1, c]: +1 x[1] y[1, c]
...
[8, b]: +1 x[8] y[8, b]
[8, c]: +1 x[8] y[8, c]
[8, d]: +1 x[8] y[8, d]
[9, a]: +1 x[9] y[9, a]
[9, b]: +1 x[9] y[9, b]
[9, c]: +1 x[9] y[9, c]
[9, d]: +1 x[9] y[9, d]
In all cases, the returned shape is the same. Note that, the output type of the multiplication is a QuadraticExpression and not a LinearExpression.
The z expression, which carries along x and y, has different attributes such as coord_dims, dims, size.
[5]:
z.coord_dims
[5]:
('time', 'port')
Important
Under the v1 convention, operands that share a dimension must carry the same coordinate labels on it — linopy aligns by label, never by position. Combining objects whose labels differ on a shared dimension raises a ValueError instead of silently overwriting coordinates. For example:
[6]:
other_time = pd.Index(range(10, 20), name="time")
b = m.add_variables(coords=[other_time], name="b")
b
[6]:
Variable (time: 10)
-------------------
[10]: b[10] ∈ [-inf, inf]
[11]: b[11] ∈ [-inf, inf]
[12]: b[12] ∈ [-inf, inf]
[13]: b[13] ∈ [-inf, inf]
[14]: b[14] ∈ [-inf, inf]
[15]: b[15] ∈ [-inf, inf]
[16]: b[16] ∈ [-inf, inf]
[17]: b[17] ∈ [-inf, inf]
[18]: b[18] ∈ [-inf, inf]
[19]: b[19] ∈ [-inf, inf]
b has the same shape as x, but their time labels differ (x spans 0–9, b spans 10–19). Because time is shared, combining them directly raises:
[7]:
try:
x + b
except ValueError as e:
print(e)
Coordinate mismatch on shared dimension 'time': left=[0, 1, 2, 3, 4, 5, ... (10 total)], right=[10, 11, 12, 13, 14, 15, ... (10 total)]. Resolve with `.sel(...)` / `.reindex(...)` to align before combining, with `.assign_coords(...)` to relabel one side (positional alignment, made explicit), with `linopy.align(...)` to pre-align several operands at once, or by passing an explicit `join=` argument to `.add` / `.sub` / `.mul` / `.div` / `.le` / `.ge` / `.eq` (accepts inner / outer / left / right / override).
To combine them anyway, say how the coordinates should be reconciled — e.g. an outer join over the union of both time ranges, where each position keeps whichever operand is defined there (the missing side contributes 0):
[8]:
x.add(b, join="outer")
[8]:
LinearExpression [time: 20]:
----------------------------
[0]: +1 x[0]
[1]: +1 x[1]
[2]: +1 x[2]
[3]: +1 x[3]
[4]: +1 x[4]
[5]: +1 x[5]
[6]: +1 x[6]
...
[13]: +1 b[13]
[14]: +1 b[14]
[15]: +1 b[15]
[16]: +1 b[16]
[17]: +1 b[17]
[18]: +1 b[18]
[19]: +1 b[19]
Tip
For explicit control over how coordinates are aligned during arithmetic, use the .add(), .sub(), .mul(), and .div() methods with a join parameter ("inner", "outer", "left", "right"). See the :doc:coordinate-alignment guide for details.
Using .loc to select a subset#
The .loc function allows you to select a subset of the array using indexes. This is useful when you want to apply operations to a specific subset of your variables.
For example, if you want to apply a summation to the variables x and y only for the first 5 time steps, you can do so as follows:
[9]:
x.loc[:5]
[9]:
Variable (time: 6)
------------------
[0]: x[0] ∈ [0, inf]
[1]: x[1] ∈ [0, inf]
[2]: x[2] ∈ [0, inf]
[3]: x[3] ∈ [0, inf]
[4]: x[4] ∈ [0, inf]
[5]: x[5] ∈ [0, inf]
[10]:
x.loc[:5] + y.loc[:5]
[10]:
LinearExpression [time: 6, port: 4]:
------------------------------------
[0, a]: +1 x[0] + 1 y[0, a]
[0, b]: +1 x[0] + 1 y[0, b]
[0, c]: +1 x[0] + 1 y[0, c]
[0, d]: +1 x[0] + 1 y[0, d]
[1, a]: +1 x[1] + 1 y[1, a]
[1, b]: +1 x[1] + 1 y[1, b]
[1, c]: +1 x[1] + 1 y[1, c]
...
[4, b]: +1 x[4] + 1 y[4, b]
[4, c]: +1 x[4] + 1 y[4, c]
[4, d]: +1 x[4] + 1 y[4, d]
[5, a]: +1 x[5] + 1 y[5, a]
[5, b]: +1 x[5] + 1 y[5, b]
[5, c]: +1 x[5] + 1 y[5, c]
[5, d]: +1 x[5] + 1 y[5, d]
which is the same as
[11]:
expr = x + y
expr.loc[:5]
[11]:
LinearExpression [time: 6, port: 4]:
------------------------------------
[0, a]: +1 x[0] + 1 y[0, a]
[0, b]: +1 x[0] + 1 y[0, b]
[0, c]: +1 x[0] + 1 y[0, c]
[0, d]: +1 x[0] + 1 y[0, d]
[1, a]: +1 x[1] + 1 y[1, a]
[1, b]: +1 x[1] + 1 y[1, b]
[1, c]: +1 x[1] + 1 y[1, c]
...
[4, b]: +1 x[4] + 1 y[4, b]
[4, c]: +1 x[4] + 1 y[4, c]
[4, d]: +1 x[4] + 1 y[4, d]
[5, a]: +1 x[5] + 1 y[5, a]
[5, b]: +1 x[5] + 1 y[5, b]
[5, c]: +1 x[5] + 1 y[5, c]
[5, d]: +1 x[5] + 1 y[5, d]
Sometimes you deliberately want to combine two different selections positionally — pairing them by order rather than by label. Since their time labels differ, ask for it explicitly with join="override", which adopts the left operand’s coordinates:
[12]:
x.loc[:4].add(y.loc[5:], join="override")
[12]:
LinearExpression [time: 5, port: 4]:
------------------------------------
[0, a]: +1 x[0] + 1 y[5, a]
[0, b]: +1 x[0] + 1 y[5, b]
[0, c]: +1 x[0] + 1 y[5, c]
[0, d]: +1 x[0] + 1 y[5, d]
[1, a]: +1 x[1] + 1 y[6, a]
[1, b]: +1 x[1] + 1 y[6, b]
[1, c]: +1 x[1] + 1 y[6, c]
...
[3, b]: +1 x[3] + 1 y[8, b]
[3, c]: +1 x[3] + 1 y[8, c]
[3, d]: +1 x[3] + 1 y[8, d]
[4, a]: +1 x[4] + 1 y[9, a]
[4, b]: +1 x[4] + 1 y[9, b]
[4, c]: +1 x[4] + 1 y[9, c]
[4, d]: +1 x[4] + 1 y[9, d]
Using .where to select active variables or expressions#
The .where function allows you to select where the variable or expression should be active or not. This is useful when you want to apply constraints or operations only to a specific subset of your variables based on a condition. It is quite similar to the functionality of masking, that we showed earlier.
For example, if you want to create an sum of the variables x and y where time is greater than 2, you can do so as follows:
[13]:
mask = xr.DataArray(time > 2, coords=[time])
(x + y).where(mask)
[13]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: None
[0, b]: None
[0, c]: None
[0, d]: None
[1, a]: None
[1, b]: None
[1, c]: None
...
[8, b]: +1 x[8] + 1 y[8, b]
[8, c]: +1 x[8] + 1 y[8, c]
[8, d]: +1 x[8] + 1 y[8, d]
[9, a]: +1 x[9] + 1 y[9, a]
[9, b]: +1 x[9] + 1 y[9, b]
[9, c]: +1 x[9] + 1 y[9, c]
[9, d]: +1 x[9] + 1 y[9, d]
We can use this to make a conditional summation:
[14]:
(x + y).where(mask) + xr.DataArray(5, coords=[time]).where(~mask, 0)
[14]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: None
[0, b]: None
[0, c]: None
[0, d]: None
[1, a]: None
[1, b]: None
[1, c]: None
...
[8, b]: +1 x[8] + 1 y[8, b]
[8, c]: +1 x[8] + 1 y[8, c]
[8, d]: +1 x[8] + 1 y[8, d]
[9, a]: +1 x[9] + 1 y[9, a]
[9, b]: +1 x[9] + 1 y[9, b]
[9, c]: +1 x[9] + 1 y[9, c]
[9, d]: +1 x[9] + 1 y[9, d]
Sometimes .where may lead to a situation where some of the variables are completely masked
[15]:
mask_a = xr.DataArray(False, coords=[time])
mask_b = xr.DataArray(time > 2, coords=[time])
z = (x.where(mask_a) + y).where(mask_b)
z
[15]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: None
[0, b]: None
[0, c]: None
[0, d]: None
[1, a]: None
[1, b]: None
[1, c]: None
...
[8, b]: None
[8, c]: None
[8, d]: None
[9, a]: None
[9, b]: None
[9, c]: None
[9, d]: None
In this example you can see that many of the elements of the LinearExpression are None. If you want to remove all the None terms, you can use .where(.., drop=True)
[16]:
z = z.where(mask_b, drop=True)
z
[16]:
LinearExpression [time: 7, port: 4]:
------------------------------------
[3, a]: None
[3, b]: None
[3, c]: None
[3, d]: None
[4, a]: None
[4, b]: None
[4, c]: None
...
[8, b]: None
[8, c]: None
[8, d]: None
[9, a]: None
[9, b]: None
[9, c]: None
[9, d]: None
That looks nicer!
You may notice that the variable x is not used at all. The expression still contains two terms (one of them is unused) but it only has one variable y
[17]:
z.nterm
[17]:
2
[18]:
z.variable_names
[18]:
set()
You can get rid of the unused term with .simplify()
[19]:
z = z.simplify()
z.nterm
[19]:
0
Using .shift to shift the Variable along one dimension#
The .shift function allows you to shift the whole array along one dimension. This is useful when you want to apply constraints or operations that involve a time delay or a shift in the time steps.
For example, if you want to apply a constraint that involves a one time step delay in the variables x and y, you can do so as follows:
[20]:
y - y.shift(time=1)
[20]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: None
[0, b]: None
[0, c]: None
[0, d]: None
[1, a]: +1 y[1, a] - 1 y[0, a]
[1, b]: +1 y[1, b] - 1 y[0, b]
[1, c]: +1 y[1, c] - 1 y[0, c]
...
[8, b]: +1 y[8, b] - 1 y[7, b]
[8, c]: +1 y[8, c] - 1 y[7, c]
[8, d]: +1 y[8, d] - 1 y[7, d]
[9, a]: +1 y[9, a] - 1 y[8, a]
[9, b]: +1 y[9, b] - 1 y[8, b]
[9, c]: +1 y[9, c] - 1 y[8, c]
[9, d]: +1 y[9, d] - 1 y[8, d]
Using .groupby to group by a key and apply operations on the groups#
The .groupby function allows you to group by a key and apply operations on the groups. This is useful when you want to apply constraints or operations that involve a grouping of the time steps or any other dimension.
For example, if you want to apply a constraint that involves the sum of x and y over every two time steps, you can do so as follows:
[21]:
group_key = pd.Series(time.values // 2, index=time)
(x + y).groupby(group_key).sum()
[21]:
LinearExpression [group: 5, port: 4]:
-------------------------------------
[0, a]: +1 x[0] + 1 x[1] + 1 y[0, a] + 1 y[1, a]
[0, b]: +1 x[0] + 1 x[1] + 1 y[0, b] + 1 y[1, b]
[0, c]: +1 x[0] + 1 x[1] + 1 y[0, c] + 1 y[1, c]
[0, d]: +1 x[0] + 1 x[1] + 1 y[0, d] + 1 y[1, d]
[1, a]: +1 x[2] + 1 x[3] + 1 y[2, a] + 1 y[3, a]
[1, b]: +1 x[2] + 1 x[3] + 1 y[2, b] + 1 y[3, b]
[1, c]: +1 x[2] + 1 x[3] + 1 y[2, c] + 1 y[3, c]
...
[3, b]: +1 x[6] + 1 x[7] + 1 y[6, b] + 1 y[7, b]
[3, c]: +1 x[6] + 1 x[7] + 1 y[6, c] + 1 y[7, c]
[3, d]: +1 x[6] + 1 x[7] + 1 y[6, d] + 1 y[7, d]
[4, a]: +1 x[8] + 1 x[9] + 1 y[8, a] + 1 y[9, a]
[4, b]: +1 x[8] + 1 x[9] + 1 y[8, b] + 1 y[9, b]
[4, c]: +1 x[8] + 1 x[9] + 1 y[8, c] + 1 y[9, c]
[4, d]: +1 x[8] + 1 x[9] + 1 y[8, d] + 1 y[9, d]
Using .rolling to perform a rolling operation#
The .rolling function allows you to perform a rolling operation and apply operations. This is useful when you want to apply constraints or operations that involve a rolling window of the time steps or any other dimension.
For example, if you want to apply a constraint that involves the sum of x over a rolling window of 3 time steps, you can do so as follows:
[22]:
x.rolling(time=3).sum()
[22]:
LinearExpression [time: 10]:
----------------------------
[0]: +1 x[0]
[1]: +1 x[0] + 1 x[1]
[2]: +1 x[0] + 1 x[1] + 1 x[2]
[3]: +1 x[1] + 1 x[2] + 1 x[3]
[4]: +1 x[2] + 1 x[3] + 1 x[4]
[5]: +1 x[3] + 1 x[4] + 1 x[5]
[6]: +1 x[4] + 1 x[5] + 1 x[6]
[7]: +1 x[5] + 1 x[6] + 1 x[7]
[8]: +1 x[6] + 1 x[7] + 1 x[8]
[9]: +1 x[7] + 1 x[8] + 1 x[9]
Storing expressions on the model#
The expressions we have built so far are ordinary Python objects: they live in a notebook variable but are not attached to the model in any way. Sometimes you want to reuse the same expression in several constraints, in the objective, or inspect it after solving — for that, m.add_expressions registers an expression under a name on the model, similar to how m.add_variables registers a variable.
If you don’t pass a name, one is generated automatically (expr0, expr1, …). Both LinearExpression and QuadraticExpression can be stored.
[23]:
total = m.add_expressions(x + y, name="total")
total
[23]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: +1 x[0] + 1 y[0, a]
[0, b]: +1 x[0] + 1 y[0, b]
[0, c]: +1 x[0] + 1 y[0, c]
[0, d]: +1 x[0] + 1 y[0, d]
[1, a]: +1 x[1] + 1 y[1, a]
[1, b]: +1 x[1] + 1 y[1, b]
[1, c]: +1 x[1] + 1 y[1, c]
...
[8, b]: +1 x[8] + 1 y[8, b]
[8, c]: +1 x[8] + 1 y[8, c]
[8, d]: +1 x[8] + 1 y[8, d]
[9, a]: +1 x[9] + 1 y[9, a]
[9, b]: +1 x[9] + 1 y[9, b]
[9, c]: +1 x[9] + 1 y[9, c]
[9, d]: +1 x[9] + 1 y[9, d]
The stored expressions are reachable through m.expressions, which behaves like a dict of expressions:
[24]:
m.expressions
[24]:
linopy.model.Expressions
------------------------
* total (time, port)
[25]:
# equivalent to m.expressions.total
m.expressions["total"]
[25]:
LinearExpression [time: 10, port: 4]:
-------------------------------------
[0, a]: +1 x[0] + 1 y[0, a]
[0, b]: +1 x[0] + 1 y[0, b]
[0, c]: +1 x[0] + 1 y[0, c]
[0, d]: +1 x[0] + 1 y[0, d]
[1, a]: +1 x[1] + 1 y[1, a]
[1, b]: +1 x[1] + 1 y[1, b]
[1, c]: +1 x[1] + 1 y[1, c]
...
[8, b]: +1 x[8] + 1 y[8, b]
[8, c]: +1 x[8] + 1 y[8, c]
[8, d]: +1 x[8] + 1 y[8, d]
[9, a]: +1 x[9] + 1 y[9, a]
[9, b]: +1 x[9] + 1 y[9, b]
[9, c]: +1 x[9] + 1 y[9, c]
[9, d]: +1 x[9] + 1 y[9, d]
Stored expressions also show up in the model’s overview, next to the variables and constraints:
[26]:
m
[26]:
Linopy LP model
===============
Variables:
----------
* x (time)
* y (time, port)
* b (time)
Expressions:
------------
* total (time, port)
Constraints:
------------
<empty>
Status:
-------
initialized
Tip
After solving the model, m.expressions.solution returns an xarray.Dataset with one entry per stored expression, evaluated at the optimal solution — handy for inspecting derived quantities without rebuilding the expression by hand.